Skip to content

Parent class constructor defaults are not safely scoped in child constructors #609

@mitchelloharawild

Description

@mitchelloharawild

The default constructor's inheritance of parent constructor formals seems unsafe, since the evaluation environment might change. I encountered this when developing an S7 class in a package with a custom constructor, and then extending this class in a different package with the default constructor.

Perhaps passing parent properties to the parent constructor via ... is a safer alternative? This would also fix #467 and #585.

Explanatory MRE:

library(S7)

The built-in constructor evaluates default arguments.

e <- new.env()
local({
  default <- 1L
  parent <- new_class(
    "parent",
    properties = list(
      x = new_property(class_integer, default = default)
    )
  )
}, envir = e)

# evaluated property defaults are used in formals
formals(e$parent)
#> $x
#> [1] 1
e$parent()
#> <parent>
#>  @ x: int 1

Suppose we prefer the constructor's formals to maintain the symbol defaults

# Custom constructor for unevaluated defaults
local({
  default <- 1L
  parent <- new_class(
    "parent",
    properties = list(
      x = new_property(class_integer, default = default)
    ),
    constructor = function(x = default) {
      new_object(S7::S7_object(), x = x)
    }
  )
}, envir = e)

# Constructor with symbol property defaults used in formals
formals(e$parent)
#> $x
#> default
e$parent()
#> <parent>
#>  @ x: int 1

This works, the symbols are found from the correct environment when needed.

Then we inherit this class as a parent from a class in a different environment.

child <- new_class(
  "child", 
  e$parent,
  properties = list(
    y = new_property(class_integer, default = 0L)
  )
)

# Parent constructor formals are copied to child constructor
# But the environment is now different, so the symbols cannot be found
formals(child)
#> $x
#> default
#> 
#> $y
#> [1] 0
child()
#> Error in `child()`:
#> ! object 'default' not found

The parent constructor's formals are merged with the child constructor's formals, and evaluated in the child constructor's environment -- so the symbol cannot be found.

If we instead pass through parent properties to the parent constructor via ..., the appropriate arguments are evaluated in the appropriate environment.

# Alternatively, passing ... to parent methods evaluates formals in the
# appropriate context/environment.
child <- new_class(
  "child", 
  e$parent,
  properties = list(
    y = new_property(class_integer, default = 0L)
  ),
  constructor = function(..., y = 0L) {
    new_object(e$parent(...), y = y)
  }
)
formals(child)
#> $...
#> 
#> 
#> $y
#> [1] 0
child()
#> <child>
#>  @ x: int 1
#>  @ y: int 0

Created on 2026-05-06 with reprex v2.1.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugan unexpected problem or unintended behaviorconstruction 🚧

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions