Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Non-generic methods for S7 Objects #436

Closed
JosiahParry opened this issue Sep 6, 2024 · 14 comments
Closed

Non-generic methods for S7 Objects #436

JosiahParry opened this issue Sep 6, 2024 · 14 comments

Comments

@JosiahParry
Copy link

I am trying to work with S7 in an R package for the first time and am struggling a bit with reconciling S7 with other OOO approaches I've worked with.

Typically, I can define a class and their properties. Then for the class, I can define methods that can be called from the class. I would like to do this with S7 but from what I can tell, it is only possible to create generic methods for S7. And it is not possible to create methods for an S7 class.

What I have been toying with is setting a prop as class_function but this allows it to be any function. Not a specific function id like to associate with the class.

Is this possible today?

e.g. in rust i might write:

struct Person {
  name: String
  age: i32
}

impl Person {
  fn greet(self) { println!("Hello, I am {}!", self.name); }
}

in S7 the equivalent might be something like

new_class("person", properties = list(name = class_character, age = class_integer))

but i cannot understand how to create a greet() method

@tdeenes
Copy link

tdeenes commented Sep 6, 2024

Have you already gone through the vignettes? In particular, this and this.

person <- new_class("person", properties = list(name = class_character, age = class_integer))
greet <- new_generic("greet", "x")
method(greet, person) <- function(x) message("Hello, ", x@name, "!")

JohnDoe <- person(name = "John Doe", age = 30L)
greet(JohnDoe)
# Hello, John Doe!

@JosiahParry
Copy link
Author

yes indeed. That is a generic method though. I would like to define a method that is not generic and cannot be extended

@tdeenes
Copy link

tdeenes commented Sep 6, 2024

If this is what you want, R6 might be a better fit for you. Having said that, you can achieve this in S7, too:

greet_prop <- new_property(
  class_function,
  getter = function(self) {
      function() message("Hello, ", self@name, "!")
  }
)
person <- new_class(
  "person",
  properties = list(name = class_character, age = class_integer, greet = greet_prop)
)

JohnDoe <- person(name = "John Doe", age = 30L)
JohnDoe@greet()

@JosiahParry
Copy link
Author

Ah! That is interesting! But in this case the property would be a misnamed method.

@t-kalinowski
Copy link
Member

t-kalinowski commented Sep 6, 2024

S7, like S3 and S4, is built around a generic function OOP system, which differs from encapsulated systems like Rust, R6, or Python. This is very well described by @hadley in Advanced R, https://adv-r.hadley.nz/oo.html#oop-systems, which I'll quote here:

  • In encapsulated OOP, methods belong to objects or classes, and method calls typically look like object.method(arg1, arg2). This is called encapsulated because the object encapsulates both data (with fields) and behaviour (with methods), and is the paradigm found in most popular languages.
  • In functional OOP, methods belong to generic functions, and method calls look like ordinary function calls: generic(object, arg2, arg3). This is called functional because from the outside it looks like a regular function call, and internally the components are also functions.

In most simple cases, though, the difference between these two approaches is more about syntax than underlying functionality.

For example, in Rust, you can call methods either way:

let p = Person {name: "Mary", age: 5};

p.greet();      // Method call via object
Person::greet(p); // Equivalent function call

Similarly, in Python:

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age
  
  def greet(self):
    print(f"Hi, I am {self.name}!")

p = Person("Mary", 5)
p.greet()         # Object-based method call
Person.greet(p)   # Equivalent function call

In a generic-function-based system like S7, the sugar differs slightly. The generic function dispatches to a method based on the class of the object, instead of the object carrying around a reference to the method to call. 

So yes, in S7, a method that dispatches must be associated with a generic function, and you won’t be able to attach methods directly to the class as you would in encapsulated OOP.

That said, there are some workarounds. The previous comment by @tdeenes shows how to overload a property: 

Person <- new_class("Person", properties = list(
  name = class_character,
  age = class_integer,
  greet = new_property(class_function, getter = function(self) {
    function() cat(sprintf("Hi, I am %s!\n", self@name))
  })
))

p <- Person(name = "Mary", age = 30)
p@greet()  # Prints "Hi, I am Mary!"

If you're simply trying to create a method that can't be extended (and that takes an instance as the first argument), you could simply define a standalone function.

greet <- function(x) {
  stopifnot(inherits(x, "Person"))
  cat(sprintf("Hi, I am %s!\n", self@name))
}

If you really want to mimic the x$meth() syntax, but don't want the methods to show up in the default print method for Person objects, you could implement a workaround using $ for method resolution:

method(`$`, Person) <- function(x, name) {
  method <- get(name, mode = "function", envir = parent.frame())
  function(...) method(x, ...)
}

p$greet()  # Calls greet method

This approach gives you some of the encapsulated syntax, but it does come with caveats and isn't idiomatic S7.

@hadley
Copy link
Member

hadley commented Sep 7, 2024

Since this isn't something we're planning on adding to S7 in the near future, I'm going to close this issue, but feel free to keep the discussion going here.

@hadley hadley closed this as completed Sep 7, 2024
@t-kalinowski
Copy link
Member

See also: #202

@lawremi
Copy link
Collaborator

lawremi commented Sep 11, 2024

Moving from message-passing to functional OOP benefits from a shift in how we think about software. Typically, OOP is based on passing messages between objects, which are agents that do something in response to a message. Often, a message requires an object to change state, which means it convenient for it to be mutable.

In functional OOP, however, generic functions are stateless polymorphic agents (like a REST API), and the objects come to represent only state. Since generics transform state, it is convenient for the state objects to be immutable.

Thus, mutability and message-passing tend to be coupled, and the two paradigms become distinct enough to be treated separately. Since much of programming in R involves transformation of state (data), functional OOP is the convention. I would recommend reference classes (from the methods package) or R6 as complements to S7 for the exceptional use cases.

@JosiahParry
Copy link
Author

JosiahParry commented Sep 11, 2024

@lawremi to me, the key selling point of S7 is the type safety it provides as well as multiple dispatch—which S3, R6, and S4 do not provide. It is far less about a functional paradigm. R is a fully featured language and something like S7 could help bring R into the modern era with additional focus on type safety—a la typescript, pydantic (now with type hinting in 3.6), the success of Rust.

I think immutability is a great default for end users but as package developers being able to have mutable objects can be very important and useful. Particularly when storing large objects. Cloning these objects frequently can bloat memory usage and puts a larger strain on the GC.

I have played with a hybrid approach here if you are at all interested: https://gist.github.com/JosiahParry/86c9ed8417a3eb21a23b977e90947562

@lawremi
Copy link
Collaborator

lawremi commented Sep 12, 2024

S4 provides some degree of type safety and multiple dispatch. But neither S4 nor S7 get close to the other examples you mention, which bring typing to the language itself. With respect to scalability, in my experience requirements quickly graduate from reference semantics to more sophisticated distributed computing and out-of-core approaches. This is definitely something we will continue thinking about, though. Thanks for the push.

@t-kalinowski
Copy link
Member

I think that encapsulated methods as properties, similar to how @tdeenes showed, is something people will want and use regularly.

The mechanics are a little tricky, and I wonder if we should export a helper from S7 to make this easier (perhaps as part of the "convenience props" family in #433).

(new_encapsulated_method seems like an overly long name - a better name would follow a convention established in #433)

new_encapsulated_method <- function(method, name = NULL) {
  frmls <- formals(method)
  stopifnot("method first argument must be named 'self'" =
              identical("self", names(frmls)[1]))
  passthroughs <- as_names(names(frmls), named = TRUE)
  names(passthroughs)[names(passthroughs) == "..."] <- ""
  method_sans_self <- new_function(
    args = frmls[-1],
    body = as.call(c(method, passthroughs)),
    env = baseenv()
  )
  getter <- function(self) {
    environment(method_sans_self) <- environment()
    method_sans_self
  }
  environment(getter) <- list2env(
    list(method_sans_self = method_sans_self),
    parent = baseenv()
  )
  new_property(class_function, getter = getter, name = name)
}
environment(new_encapsulated_method) <- asNamespace("S7")

Usage example:

Thing <- new_class("Thing", properties = list(
  time_created = new_property(class_POSIXct, default = quote(Sys.time())),
  age = new_encapsulated_method(function(self) {
    Sys.time() - self@time_created
  }),
  id = new_property(class_character, default = quote(sub(
    ".*0x([0-9a-z]+).*", "\\1", format(environment())
  ))),
  log = new_encapsulated_method(function(self, ..., sep = "") {
    cat("[", self@id, "] ", ..., sep = "")
  }))
)

thing <- Thing()
thing
#> <Thing>
#>  @ time_created: POSIXct[1:1], format: "2024-10-10 08:11:51"
#>  @ age         : function ()  
#>  @ id          : chr "13ad9e370"
#>  @ log         : function (..., sep = "")

thing@time_created
#> [1] "2024-10-10 08:11:51 EDT"

thing@age
#> function () 
#> (function (self) 
#> {
#>     Sys.time() - self@time_created
#> })(self = self)
#> <environment: 0x11a9d8558>

thing@age()
#> Time difference of 0.0230968 secs

thing@id
#> [1] "13ad9e370"

thing@log
#> function (..., sep = "") 
#> (function (self, ..., sep = "") 
#> {
#>     cat("[", self@id, "] ", ..., sep = "")
#> })(self = self, ..., sep = sep)
#> <environment: 0x11a8deb48>

thing@log("hello")
#> [13ad9e370] hello

@lawremi
Copy link
Collaborator

lawremi commented Oct 13, 2024

Thanks for sketching this out. What use cases do we foresee with this syntax? It seems to me the only advantage is the avoidance of name collisions, at the cost of functional syntax. The syntax might suggest that the objects behave as in the message-passing paradigm, which is not the case (less encapsulation of state, no mutability).

@t-kalinowski
Copy link
Member

Indeed, the primary motivation would be avoiding name collisions and aiding discovery. However, a better approach to discovery might be to focus on #435.

@lawremi
Copy link
Collaborator

lawremi commented Oct 15, 2024

Great point about aiding discovery. That's a nice feature of S7 properties. The S4 slots could never really be that, due to the lack of encapsulation (getters). I guess the pipe operator could help with that? Like object |> generic() could autocomplete generic based on the available methods. Just need some way to filter out the non-generics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants