Sooner or later every successful package needs to change its interface: a generic gains an argument, a property turns out to be misnamed, or a class needs to be split in two. When other packages depend on yours, these changes are no longer private decisions, and you need to think through how your changes affect dependencies. This vignette works through the ways an S7 interface can change and the consequences for downstream packages.
Throughout this vignette, we’ll imagine two packages:
This vignette addresses the case where both packages are on CRAN. Releasing two CRAN packages at the same time is painful and best avoided, so every breaking change needs a path where depPackage can release a version that works with both old and new yourPackage. In practice, you may have many dependencies; depPackage stands in for any one of them.
This vignette shows you how to use the tools that S7 provides, and describes the changes that can’t be made without breaking depPackage.
The vignette is divided into changes to generics and changes to classes. Each starts by explaining when things break, then goes into the details of each possible change and how to make it with the least disruption to depPackage. We finish with some practices that make your package easier to evolve, and a table summarising every change.
Most changes to a generic cause problems with method registration.
depPackage’s method<- calls run when depPackage is
installed, and the registrations are replayed each time it’s loaded.
Failures at either install- or load-time are problematic, because they
make it impossible to create a version of depPackage that works with
both versions of yourPackage.
For this reason, S7 only checks method compatibility in development:
devtools::load_all(),R CMD check on the package that
owns the method, the generic, or one of the classes, orIn development, S7 checks that:
..., the method’s formals match
the generic’s, except that defaults for dispatch arguments belong only
in the generic.The first two are errors (or warnings under load_all(),
so you can see them all at once), which fail R CMD check of
depPackage. The last two are warnings, which show up as a single
NOTE.
Users skip these checks entirely: an incompatible method still
installs and loads, and any problem only shows up when the method is
called. This gives the depPackage developer the ability to create a
version that works with both old and new yourPackage. So in the sections
below, a change that “breaks methods” means that depPackage fails
R CMD check, and its methods error or misbehave when users
call them.
The following sections cover each way a generic can change:
Changing a generic’s dispatch arguments (i.e. their number, names, or order) breaks every method in depPackage: no method can match both the old and new dispatch arguments. There is no workaround: a generic’s dispatch arguments are its identity. So if you change dispatch, you are creating a new generic, and that means you should be explicit about it: create a new generic with a new name and deprecate the old one. We recommend avoiding this if at all possible, but if you have to, here’s the process:
deprecated_generic(), and release.If your generic has ..., adding a non-dispatch argument
is one of the mildest changes you can make. Existing methods keep
working, but every registration produces a warning until they’re
updated:
# yourPackage, version 1
gen := new_generic("x")
# depPackage's method, written against version 1
BClass := new_class()
method(gen, BClass) <- function(x, ...) "old"
# yourPackage, version 2 adds `verbose`
gen := new_generic("x", fun = function(x, verbose = FALSE, ...) S7_dispatch())
method(gen, BClass) <- function(x, ...) "old"
#> Warning: gen(<BClass>) doesn't have argument `verbose`depPackage’s users see nothing, but the warning surfaces under
load_all() and as a single NOTE in R CMD check
of depPackage. The fix is pleasingly asymmetric: because methods may
have arguments the generic lacks, depPackage can add the argument
before yourPackage releases:
# yourPackage, version 1 again
gen := new_generic("x")
# depPackage adds `verbose` ahead of yourPackage's release: no warning under version 1...
method(gen, BClass) <- function(x, verbose = FALSE, ...) "new"
# ...and none under version 2 either
gen := new_generic("x", fun = function(x, verbose = FALSE, ...) S7_dispatch())
method(gen, BClass) <- function(x, verbose = FALSE, ...) "new"So the process is:
If your generic doesn’t have ..., adding an argument is
an error for every downstream method, and there is no version of the
method that satisfies both the old and the new generic. Either way,
there is no fix on depPackage’s side, so a generic without
... is a frozen interface: only add one deliberately, and
treat any change to it like a rename.
Removing a non-dispatch argument is similarly mild. Methods may have extra arguments, so a method that still lists the removed argument registers without complaint against both versions. depPackage can remove the argument from its methods at leisure.
Callers are the bigger concern: gen(x, removed_arg = 1)
will still be accepted by the generic (via ...) and will
reach methods that may or may not expect it. So before removing an
argument, deprecate it: keep it in the generic, and warn when it’s
supplied:
gen := new_generic("x", fun = function(x, verbose = FALSE, ...) {
if (!missing(verbose)) {
.Deprecated(msg = "The `verbose` argument is deprecated and will be removed in a future release.")
}
S7_dispatch()
})
method(gen, BClass) <- function(x, verbose = FALSE, ...) "result"
. <- gen(BClass(), verbose = TRUE)
#> Warning in gen(BClass(), verbose = TRUE): The `verbose` argument is deprecated
#> and will be removed in a future release.If you use lifecycle, the equivalent is:
gen := new_generic("x", fun = function(x, verbose = lifecycle::deprecated(), ...) {
if (lifecycle::is_present(verbose)) {
lifecycle::deprecate_warn("1.0.0", "gen(verbose)")
}
S7_dispatch()
})
method(gen, BClass) <- function(x, verbose = lifecycle::deprecated(), ...) "result"
. <- gen(BClass(), verbose = TRUE)
#> Warning: The `verbose` argument of `gen()` is deprecated as of <NA> 1.0.0.
#> This warning is displayed once per session.
#> Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
#> generated.So the process is:
Changing a default changes behavior for every caller, because S7 dispatch never uses the defaults provided by the method. For this reason S7 warns whenever the generic and method drift out of sync:
gen := new_generic("x", fun = function(x, drop = TRUE, ...) S7_dispatch())
method(gen, BClass) <- function(x, drop = FALSE, ...) drop
#> Warning: In gen(<BClass>), default value of `drop` is not the same as the generic
#> - Generic: TRUE
#> - Method: FALSE
gen(BClass())
#> [1] TRUEThere’s no way around this warning, as there’s no spelling of the method that agrees with both versions of yourPackage. Fortunately, as with adding an argument, this only costs depPackage one check NOTE. Our advice is to keep non-dispatch arguments to a minimum, and change defaults rarely, if ever.
If you must, the process is:
If you simply rename a generic, depPackage will fail to install if it
uses importFrom(yourPackage, gen). The fix is to keep the
old name as a deprecated alias for the new generic:
# yourPackage, version 1
gen1 := new_generic("x")
# yourPackage, version 2
gen2 := new_generic("x")
gen1 := deprecated_generic(new = gen2, when = "2.0.0")deprecated_generic() returns an object that still counts
as the generic, so method registration continues to work without
warning, but calls through the old name warn that it’s time to move
on.
method(gen1, BClass) <- function(x, ...) "result"
gen2(BClass())
#> [1] "result"
gen1(BClass())
#> Warning in gen1(BClass()): `gen1()` was deprecated in version 2.0.0.
#> Please use `gen2()` instead.
#> [1] "result"By default, deprecated_generic() generates a base
warning, but you can choose to use lifecycle instead:
gen1 := deprecated_generic(
new = gen2,
when = "2.0.0",
method = "lifecycle(warn)"
)
method(gen1, BClass) <- function(x, ...) "result"
gen1(BClass())
#> Warning: `gen1()` was deprecated in <NA> 2.0.0.
#> ℹ Please use `gen2()` instead.
#> This warning is displayed once per session.
#> Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
#> generated.
#> [1] "result"You can later remove gen1 once dependencies have had
time to move on.
So the process is:
deprecated_generic(new = ), and release.To retire a generic without replacement, use
deprecated_generic() without the new
argument:
# yourPackage, version 1
gen := new_generic("x")
# yourPackage, version 2
gen := deprecated_generic("x", when = "2.0.0")
method(gen, BClass) <- function(x, ...) "result"
gen(BClass())
#> Warning in gen(BClass()): `gen()` was deprecated in version 2.0.0.
#> [1] "result"Existing method registration will continue to work without warning, but calls to the generic warn, giving dependencies time to move away from the generic before you remove it in a future release.
So the process is:
deprecated_generic(), and
release.Splitting generics out into a lower-dependency package (say yourPackageCore) works, as long as yourPackage re-exports them:
depPackage’s importFrom(yourPackage, gen) still
resolves, and method registration follows the generic object to its new
home package automatically.
You must re-export using the NAMESPACE, not
assignment:
This captures a copy of the generic when yourPackage is installed.
The result is a split brain: depPackage’s registrations follow the
generic’s home package and land on yourPackageCore’s original, while
calls through yourPackage::gen() dispatch using the
duplicate’s empty table and fail.
Should the re-export signal deprecation? Since a re-export costs nothing to maintain, keeping it forever is a fine choice. If you do want to retire the old home, export a deprecated alias:
So the process is:
NAMESPACE, and release.Classes can evolve without breaking their subclasses in other packages because S7 never inlines a parent constructor across package boundaries. To see why this matters, compare the two cases. When classes live in the same package, the subclass’s constructor inlines the parent’s arguments, so you can see every property it takes:
class1 := new_class(package = "foo", properties = list(a = class_any))
class2 := new_class(class1, package = "foo", properties = list(b = class_any))
class2@constructor
#> function (a = NULL, b = NULL)
#> S7::new_object(class1(a = a), b = b)
#> <environment: 0x629d3766bd08>Things are different when the parent lives in another package:
class2 := new_class(foo::class1, properties = list(b = class_any))
class2@constructor
#> function (..., b = NULL)
#> S7::new_object(S7::as_class(foo::class1)(...), b = b)Now the constructor of class2 can’t inline the parent’s
arguments, because the parent’s properties (and hence its constructor)
might change in a future version. Instead, it takes ... and
passes them on to foo::class1() at run time, followed by
its own property, b. This is the key idea that allows
classes to evolve over time without immediately breaking their
dependencies.
The following sections cover each way a class can change:
Adding a property is usually harmless, but there are two ways it can still bite:
Name clash. If depPackage has a subclass and that subclass uses a property with the same name, depPackage will fail to install:
Error: <MyFoo>@y must narrow <yourPackage::Foo>@y.
The easiest resolution to this problem is for you to pick a different name. If that doesn’t work, you’ll need to ask the depPackage maintainer to rename their property using the process described below.
Positional construction.
yourPackage::Foo(1, 2) changes meaning if a property is
inserted before an existing one. In general, it’s best to add new
properties at the end so positional calls keep their meaning, but
inserting is usually OK because most callers use named
arguments.
So the process is:
Removing a property will cause run-time errors:
Foo(count = 2) will fail with unused argument
and x@count with Property not found. To give
dependencies time to adapt, deprecate the property with
deprecated_property():
# yourPackage, version 1
validate_count <- function(value) {
if (any(value < 0)) "must be non-negative"
}
Foo := new_class(properties = list(
count = new_property(class_double, default = 1, validator = validate_count)
))
# yourPackage, version 2
Foo := new_class(properties = list(
deprecated_property(
"count", when = "2.0.0", class = class_double, default = 1,
validator = validate_count
)
))
foo <- Foo(count = 3)
foo@count
#> Warning: `<Foo>@count` was deprecated in version 2.0.0.
#> [1] 3
foo@count <- 4
#> Warning: `<Foo>@count` was deprecated in version 2.0.0.
print(foo)
#> <Foo>So the process is:
deprecated_property(),
and release.A rename is just an add and a remove, supplying the name of the
new property to deprecated_property():
Foo := new_class(properties = list(
size = new_property(class_double, default = 1),
deprecated_property("count", new = "size", when = "2.0.0")
))
foo <- Foo(size = 3) # silent
foo <- Foo(count = 3) # warns, then sets size
#> Warning: `<Foo>@count` was deprecated in version 2.0.0.
#> Please use `<Foo>@size` instead.
foo@count
#> Warning: `<Foo>@count` was deprecated in version 2.0.0.
#> Please use `<Foo>@size` instead.
#> [1] 3
foo@count <- 4
#> Warning: `<Foo>@count` was deprecated in version 2.0.0.
#> Please use `<Foo>@size` instead.
foo@size
#> [1] 4So the process is:
deprecated_property(new = ), and release.Widening a property’s type (say, class_integer to
class_numeric) is safe for depPackage: everything that
validated before still validates. Narrowing causes run-time errors: any
code that stored a now-invalid value fails when it runs:
Error: <yourPackage::Foo> object properties are invalid:
- @size must be <integer>, not <double>
The same applies to tightening a validator. There’s no automatic fix here, but you can give dependencies a deprecation period by generating a warning in the validator:
# yourPackage, version 1
Foo := new_class(properties = list(size = class_double))
# yourPackage, version 2: warn instead of failing
Foo := new_class(
properties = list(size = class_double),
validator = function(self) {
if (self@size != trunc(self@size)) {
warning("`size` should be a whole number; this will become an error in a future release.")
}
NULL
}
)
foo <- Foo(size = 3.5)
#> Warning in validator(object): `size` should be a whole number; this will become
#> an error in a future release.
foo
#> <Foo>
#> @ size: num 3.5If you use lifecycle, lifecycle::deprecate_warn() is a
good choice here: it rate-limits to one warning every 8 hours, so
dependencies that construct many objects aren’t flooded.
So the process is:
To rename a class, create the new class, then redefine the old one
with deprecated_class(), passing the replacement to
new and recording the version in when.
# yourPackage, version 1
Foo := new_class(properties = list(size = class_double))
saved <- Foo(size = 1)
MyFoo := new_class(parent = Foo)
measure := new_generic("x")
method(measure, Foo) <- function(x) x@size
# yourPackage, version 2: export both classes
Bar := new_class(properties = list(size = class_double))
Foo := deprecated_class(
properties = list(size = class_double),
new = Bar,
when = "2.0.0"
)
foo <- Foo(size = 2) # warns and still constructs a Foo
#> Warning in Foo(size = 2): `Foo()` was deprecated in version 2.0.0.
#> Please use `Bar()` instead.
measure(foo)
#> [1] 2
measure(MyFoo(size = 3)) # existing subclasses still work, without warning
#> [1] 3
measure(saved)
#> [1] 1
S7_inherits(saved, Foo)
#> [1] TRUE
S7_inherits(saved, Bar)
#> [1] FALSEMethods for Foo do not automatically apply to
Bar. When a method can handle both classes, register it on
their union (this replaces the existing Foo method with an
identical one, hence the message):
method(measure, Foo | Bar) <- function(x) x@size
#> Overwriting method measure(<Foo>)
measure(Bar(size = 4))
#> [1] 4
measure(saved)
#> [1] 1If the replacement needs a different representation, keep separate methods and provide an explicit conversion for users who want it.
So the process is:
deprecated_class(new = ), register shared methods on the
union, and release.You can instead make the old name an alias for the replacement with
alias = TRUE:
Foo := deprecated_class(new = Bar, when = "2.0.0", alias = TRUE)
foo <- Foo(size = 2) # warns and constructs a Bar
#> Warning in Foo(size = 2): `Foo()` was deprecated in version 2.0.0.
#> Please use `Bar()` instead.
S7_inherits(foo, Bar)
#> [1] TRUE
describe := new_generic("x")
method(describe, Foo) <- function(x) paste("Size:", x@size)
describe(Bar(size = 3))
#> [1] "Size: 3"
Box := new_class(properties = list(item = Foo))
Box(item = Bar(size = 4))
#> <Box>
#> @ item: <Bar>
#> .. @ size: num 4The alias uses Bar’s definition, so you do not repeat
the properties, parent, constructor, or validator. Both names resolve to
the same class in method signatures, parents, property types, unions,
and inheritance checks. Registering or removing a method through either
name affects the same registration.
This changes what the old name means; it does not migrate existing objects or subclasses, or transfer previously registered methods. For example, the saved object created before the rename still has its original class:
Packages that captured the old class in their method signatures, subclasses, or property types need to be rebuilt against the alias. Use the default behavior above when saved objects and installed subclasses must continue to work with the old class during the transition.
To remove a class without a replacement, redefine it with
deprecated_class(), keeping its existing properties and
omitting new:
Retired := deprecated_class(
properties = list(size = class_double),
when = "2.0.0"
)
Retired(size = 3)
#> Warning in Retired(size = 3): `Retired()` was deprecated in version 2.0.0.
#> <Retired>
#> @ size: num 3Subclasses, method signatures, property types, and unions in depPackage can continue using the class, but constructors will warn.
So the process is:
deprecated_class(), and
release.You can use the same technique as renaming to move a class between
packages, because changing the class’s package also changes
its identity. To recommend a class in another package while preserving
existing uses, use deprecated_class():
# In yourPackage
Foo := deprecated_class(
properties = list(size = class_double),
new = yourPackageCore::Foo,
when = "2.0.0"
)So the process is:
deprecated_class(new = ), and release.Most of the pain described above can be avoided by a few decisions made when you first design your interface. Most of these recap advice from earlier sections:
Treat names as permanent. While there are ways to “rename” classes and generics, it’s best to think of a rename as creating a new class or generic and deprecating the old one. The same goes for a generic’s dispatch arguments and a class’s package.
Give every generic ... (the
default), so it can gain arguments later (see Adding an argument).
Keep generics as small as possible. Every
argument in the generic’s signature (even those that don’t take part in
dispatch) is part of its contract, and any changes need careful thought.
If an argument is only meaningful for some methods, leave it out of the
generic and let those methods take it via ....
Call constructors by name, not position, so adding a property never changes the meaning of a call (see Adding a property).
| Change | What breaks in depPackage | What to do |
|---|---|---|
| Change dispatch arguments | Every method: check error, calls fail | Create a new generic and deprecate the old one |
Add an argument (generic has ...) |
Check NOTE only | depPackage adds the argument first, then you release |
Add an argument (no ...) |
Every method: check error, calls fail | Treat as a rename |
| Remove an argument | Callers that supply it | Deprecate the argument, then remove |
| Change a default | Check NOTE only | Avoid |
| Rename a generic | Install, via importFrom() |
deprecated_generic(new = ) |
| Remove a generic | Install, via importFrom() |
deprecated_generic() |
| Move a generic | Nothing | Re-export via NAMESPACE |
| Add a property | Install, if a subclass has a clashing property | Pick a different name |
| Remove a property | Construction and access, at run time | deprecated_property() |
| Rename a property | Construction and access, at run time | deprecated_property(new = ) |
| Narrow a property’s type or validator | Objects with now-invalid values, at run time | Warn in the validator first |
| Rename a class | Nothing immediately | deprecated_class(new = ) |
| Remove a class | Nothing immediately | deprecated_class() |
| Move a class | Nothing immediately | deprecated_class(new = ) |