Where the default would have to guess
Most classes need nothing from this page. An S3, S4 or S7 object is a
base type plus attributes, so json_write() records it
without help: the class system says what the object is, and the
writer takes it at its word. An S4 object is its slots, an S7 object is
its properties, and new("Pt", x = 1, y = 2) is a complete
description of one.
An R6 object is where that account runs out, because no
such statement exists. What an R6 class guarantees is what
its methods say, and its private fields are private precisely because
they are not part of that. A field may be stored or derived, a private
binding may be a credential that must never leave the session, and
initialize may establish an invariant that no assignment
reproduces. Nothing in the class distinguishes those cases, and the
writer cannot tell them apart by looking.
So it does not try. Writing an instance is refused, naming the class and the method that would settle the question.
library(typedjson)
Store <- R6::R6Class("Store",
public = list(
root = NULL,
format = "rds",
initialize = function(root, key, format = "rds") {
self$root <- root
self$format <- format
private$key <- key
private$index <- private$scan()
dir.create(root, showWarnings = FALSE, recursive = TRUE)
},
put = function(name, value) {
saveRDS(value, private$file(name))
private$index <- private$scan()
private$writes <- private$writes + 1L
invisible(self)
},
get = function(name) readRDS(private$file(name)),
names = function() private$index
),
private = list(
key = NULL,
index = character(),
writes = 0L,
file = function(name) {
file.path(self$root, paste0(name, ".", self$format))
},
scan = function() {
sub("\\..*$", "", list.files(self$root))
}
)
)
root <- file.path(tempdir(), "store")
store <- Store$new(root, key = "s3cret")
store$put("a", 1:3)$put("b", letters)
store$names()
#> [1] "a" "b"
json_write_str(store)
#> Error:
#> ! an `R6` instance needs a `json_state()` method for class `Store` at `x`Deciding what the state is
The refusal is asking a question, and the four fields of this class answer it four different ways.
The root and format fields are state. They
are what makes this store this store, they are exactly what the
constructor was called with, and nothing else in the session can supply
them.
The index field is derived. It is a list of what happens
to be on disk, and private$scan() rebuilds it from
root at any time. Persisting it would record a snapshot
that goes stale the moment anything else writes to the directory — worse
than not recording it, because it would come back looking
authoritative.
The key field must not be persisted at all. It is a
credential the caller supplied, the document is a text file meant to be
read in diffs, and writing it there is how a secret ends up in a
commit.
The writes counter is the interesting one, and it is
genuinely the author’s call. If it is instrumentation, it belongs to the
session and should reset. If a caller can read it back and act on it, it
is state and has to survive. Only whoever wrote the class knows which,
and that is the argument for asking rather than guessing: a default
would have to pick, and would pick the same way for every class.
Writing the pair
Two methods settle it, modeled on the __getstate__ and
__setstate__ protocol of Python’s pickle. The first returns
a plain list of what to persist.
json_state.Store <- function(x) {
list(root = x$root, format = x$format)
}The second rebuilds. Note the first argument: reviving happens before
the object exists, so there is nothing to dispatch on yet. What
json_revive() dispatches on instead is an empty object
carrying the recorded class vector, which is why a method signature
always starts with class rather than with the object being
rebuilt.
json_revive.Store <- function(class, state) {
Store$new(state$root, key = Sys.getenv("STORE_KEY"), format = state$format)
}Going back through the class’s own constructor is the whole point.
The constructor is the interface the class offers for making one of
these, so it re-derives index, resets writes,
and takes key from wherever a key is supposed to come from
in the session doing the reading. An invariant initialize
establishes is established again, because initialize
ran.
doc <- json_write_str(store, pretty = TRUE)
cat(doc)
#> {
#> "~x": {
#> "class": [
#> "Store",
#> "R6"
#> ],
#> "state": {
#> "root": "/tmp/RtmpWtWCN7/store",
#> "format": "rds"
#> }
#> }
#> }Nothing in that document is opaque, and nothing in it is a secret.
The ~x tag says an extension method produced it,
class is what dispatches on the way back, and
state is the list the method returned, written under the
ordinary rules.
back <- json_read_str(doc)
back$names()
#> [1] "a" "b"
back$get("b")
#> [1] "a" "b" "c" "d" "e" "f" "g" "h" "i" "j" "k" "l" "m" "n" "o" "p" "q" "r" "s"
#> [20] "t" "u" "v" "w" "x" "y" "z"The revived store found its own contents by scanning, so
index is right rather than merely restored.
When the bindings really are the state
Some classes are plain records that happen to be written as
R6: every field is stored, nothing is derived, and
initialize only assigns. For those the pair above is
boilerplate, and the package ships the mechanism to skip it.
Point <- R6::R6Class("Point",
public = list(
x = 0, y = 0,
initialize = function(x = 0, y = 0) {
self$x <- x
self$y <- y
}
)
)
json_state.Point <- function(x) r6_state(x)
json_revive.Point <- function(class, state) r6_restore(class, state)
cat(json_write_str(Point$new(3, 4), pretty = TRUE))
#> {
#> "~x": {
#> "class": [
#> "Point",
#> "R6"
#> ],
#> "state": {
#> "package": "R_GlobalEnv",
#> "public": {
#> "x": 3.0,
#> "y": 4.0
#> },
#> "private": null
#> }
#> }
#> }
json_read_str(json_write_str(Point$new(3, 4)))$y
#> [1] 4The r6_state() function records the class’s package
alongside the public and private bindings, skipping methods and active
bindings; r6_restore() finds the generator again by name,
allocates an instance without running initialize, and
writes the bindings back in.
Reading past initialize is exactly what makes this the
author’s decision rather than the default. Opting in says the class has
no invariant to re-establish, no resource to acquire and no field worth
leaving out — which is true of Point and false of
Store. It also ties the document to the class’s current
shape: where the generator locks its instances, recorded state the class
no longer declares has nowhere to go and is dropped as the document is
read.
doc <- json_write_str(Point$new(3, 4))
Point <- R6::R6Class("Point",
public = list(x = 0, initialize = function(x = 0) self$x <- x),
lock_objects = TRUE
)
json_read_str(doc)$x
#> Warning: the `Point/R6` class no longer declares state the document records, so it is dropped rather than written into the instance:
#> `public$y`
#> [1] 3The warning is the honest outcome rather than a failure: the document
still holds a y, and the class no longer has anywhere to
put it. A pair written by hand would have decided that question in
json_revive(), where the class author can say what an old
document ought to mean.
Generators need no method
A class generator is a different kind of thing and is handled without help. It is recorded by the class it names and the package it was defined in, rather than by its contents, so it comes back as the same object rather than as a copy.
json_write_str(Point)
#> [1] "{\"~r6class\":{\"class\":[\"Point\",\"R6\"],\"package\":\"R_GlobalEnv\"}}"
identical(json_read_str(json_write_str(Point)), Point)
#> [1] TRUEThat lookup runs on the way out as well as on the way in, so a generator built inside a function — one no name finds again — is refused where it is written rather than producing a document that fails on the read.
json_write_str((function() R6::R6Class("Local", public = list(v = 1)))())
#> Error:
#> ! the class was defined in an environment that cannot be found again at `x`Reference classes, and where the refusal stops
A reference class instance is refused on the same grounds and settled
the same way. Half the question above already has an answer there, since
a reference class declares its fields and Class$fields()
says what the representation is. The other half is unchanged: the object
is a reference, initialize may establish an invariant that
no assignment reproduces, and a field is as good a place to keep a
credential as a private binding is.
Cache <- setRefClass("Cache",
fields = list(root = "character", key = "character")
)
json_write_str(Cache$new(root = tempdir(), key = "s3cret"))
#> Error:
#> ! a reference class instance needs a `json_state()` method for class `Cache` at `x`A method registered on the concrete class settles it, and so does one
on any class between that and envRefClass, since the chain
is what the writer looks along and what the document records. The
refusal itself sits on the envRefClass rung, so that rung
is taken, exactly as R6 is. The generator is refused rather
than recorded by the class it names, which is where this differs from
the two above: walking into one reaches the internals of the
methods package rather than anything the class
declares.
The refusal follows the label rather than the shape, and only a class system saying its instances are more than their bindings is such a label. An environment you have classed yourself says nothing of the kind, so the environment rule applies to it and its contents are written — the credential included.
vault <- new.env()
vault$root <- tempdir()
vault$key <- "s3cret"
class(vault) <- c("MyVault", "environment")
json_write_str(vault)
#> [1] "{\"~t\":\"environment\",\"~a\":{\"class\":[\"MyVault\",\"environment\"]},\"~v\":{\"parent\":{\"~t\":\"environment\",\"~v\":{\"name\":\"R_GlobalEnv\"}},\"bindings\":{\"key\":\"s3cret\",\"root\":\"/tmp/RtmpWtWCN7\"}}}"That is the environment rule holding rather than a hole in it. An
environment is its contents, which is what base R’s
serialize() records as well and what
vignette("design") argues for, and a class attribute of
your own making does not amount to a statement otherwise. Saying
otherwise is what a json_state() method is for, and it is
available for a value of this shape exactly as it is for the two
above.