Skip to content

Use := in all tests#658

Open
hadley wants to merge 1 commit into
mainfrom
test-bind-helper
Open

Use := in all tests#658
hadley wants to merge 1 commit into
mainfrom
test-bind-helper

Conversation

@hadley
Copy link
Copy Markdown
Member

@hadley hadley commented May 27, 2026

I'm very ambivalent about this. I do like the syntax, but I don't love that there's an obvious way forward to use this more broadly (since it would conflict with existing tidyeval and data.table syntax). Using a custom operator (e.g. %<-%) would be easier, but is less compelling because of the way air formats it (multi-line RHS are formatted like |>, not <-). Reading the tests is a reasonable way to learn about S7 best practices so I really don't like that we're using a syntax that's not widely available.

So all that given I think I'm leaning towards dropping := rather than using it more heavily.

Fixes #466

I'm very ambivalent about this. I do like the syntax, but I don't love that there's an obvious way forward to use this more broadly (since it would conflict with existing tidyeval and data.table syntax). Using a custom operator (e.g. `%<-%`) would be easier, but is less compelling because of the way air formats it (multi-line RHS are formatted like `|>`, not `<-`). Reading the tests is a reasonable way to learn about S7 best practices so I really don't like that we're using a syntax that's not widely available.

So all that given I think I'm leaning towards dropping `:=` rather than using it more heavily.

Fixes #466
@hadley hadley requested a review from t-kalinowski May 27, 2026 16:59
@lawremi
Copy link
Copy Markdown
Collaborator

lawremi commented May 27, 2026

I'm not sure if the tidyeval semantics have evolved, but at the time this was discussed, it seemed like a single implementation could handle both uses, i.e. they were mutually exclusive. The usage by data.table can be seen as an element of their DSL.

Copy link
Copy Markdown
Member

@t-kalinowski t-kalinowski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WRT data.table and rlang, that operator is not meant to be invoked as a normal operator, it's handled specially in custom evaluators from the packages. (data.table::`:=` and rlang::`:=` are just stubs that error if called, telling the user something went wrong). I don't think exporting := from S7 will conflict with data.table or rlang.

I agree that having this in tests but not exported is not of much value. We should either decide to export and use everywhere, or drop the idea.

I like the syntax, and don't see a strong reason not to use it. I think we should add it.

@hadley
Copy link
Copy Markdown
Member Author

hadley commented May 29, 2026

@lawremi can you break the tie on this?

I'm mildly against: I'm worried that := is going to be confusing for readers, not sure that this could ever make it into base R, and if we only use it internally for tests, it's going make it harder for people to use the tests as a way to learn S7.

@t-kalinowski likes the syntax, thinks it's fine to export it from S7 since it won't cause problems with data.table/tidyeval, it's overall a meaningful improvement in clarity.

@lawremi
Copy link
Copy Markdown
Collaborator

lawremi commented May 31, 2026

Thanks for the opportunity to be the tie breaker on this :)

I think the decision here is whether to export and document := or not use it at all, since any half measures would be undesirable.

The question for me has always been whether := is viable for inclusion in base R. Until now, := has unintentionally served as a "free" infix operator for use in DSLs. There's some value to that; however, now that there are multiple DSLs that use it in different ways, there might as well be a base definition with its own behavior.

The question then is whether "name-aware assignment" is a general enough concept to be worth extending the language. Interestingly, as of Python 3.6, in the class declaration context, a field assignment will call .__set_name__() on the value.

class Field:
    def __set_name__(self, owner, name):
        self.owner = owner
        self.name = name

class Person:
    age = Field()

Taking inspiration from that, := could be implemented on top of a bind_as(x, name) generic:

`:=` <- function(x, y) {
  sym <- substitute(x)
  stopifnot(is.symbol(sym))
  name <- as.character(sym)
  eval.parent(substitute(x <- bind_as(y, name)))
}

bind_as <- function(x, name, ...) UseMethod("bind_as")

bind_as.S7_class_prototype <- function(x, name, ...) {
  call <- substitute(x)
  call$name <- name
  eval.parent(call)
}

where the following is added to the top of new_class():

if (missing(name)) {
    return(structure(sys.call(), class = "S7_class_prototype"))
}

Using := for this is arguably better than Python's approach, because it's more explicit about the binding behavior, and of course it will work in all standard evaluation contexts.

Beyond S7, the applications are numerous, including logger construction, provenance tracking like on model objects (they track their call, why not their name?), and even the targets package:

model := tar_target({
  fit_model(data)
})

instead of:

tar_target(model, fit_model(data))

That might work with the current implementation since the first argument of tar_target() is name.

Actually, it even works now for S7 props:

Foo := new_class(properties = list(x = class_numeric))
foo <- Foo(1)
x := prop(foo)
x
# [1] 1

but I'm not sure if it's beneficial.

Other examples of this potentially helping popular packages:

port := config::get() # uses 'value='
width := shiny::reactive(input$width *2) # uses 'label='

Maybe we can convince them to add a name= arg as an alias?

Beyond name-aware assignment, I couldn't think of any other uses for :=, assuming its syntax makes it only applicable to assignment, but we could make := itself a generic, dispatching on the y argument. That makes it fully extensible, at the cost of the currently well defined semantics.

`:=` <- function(x, y) UseMethod(":=", y)

`:=.S7_class_prototype` <- function(x, y) {
  sym <- substitute(x)
  stopifnot(is.symbol(sym))
  y$name <- as.character(sym)
  eval.parent(substitute(x <- y))
}

These decisions can be made later though. We can make it as generic as it needs to be, but I wouldn't change it right now, because the argument insertion is so elegant.

In conclusion, I think there's pretty strong justification to define := in base R in a way that enables the current syntax. R needs better syntax (and tooling) for writing software. I hereby rule in favor of Tomasz :) Congrats, we just contributed a new type of assignment operator to computer science.

@t-kalinowski
Copy link
Copy Markdown
Member

t-kalinowski commented May 31, 2026

I like it!

Just to make sure I understand, this is a little different from := as discussed previously. Previous proposals had := modify the RHS call before evaluating it, e.g. making something like:

Foo := new_class()

behave more like:

Foo <- new_class(name = "Foo")

But with a generic (bind_as() or :=), the behavior must necessarily be different. Since S3 dispatch needs to evaluate the dispatch argument to choose a method, the RHS has already been evaluated by the time the method runs. So, if I’m understanding correctly, supporting S3 dispatch means moving away from “modify the call before evaluation” and instead working on the evaluated object.

That means this form:

Foo := new_class()

would be closer to:

Foo <- bind_as(new_class(), "Foo")

rather than:

Foo <- new_class(name = "Foo")

One minor downside is that this means package authors need to explicitly add support for late naming or renaming.

What do you think about also exporting a default := method that sets an attr(, "name")? That way it could just work with environments and functions too.

E.g.,

`:=` <- function(x, value) UseMethod(":=", value)

`:=.default` <- function(x, value) {
  stopifnot(is.symbol(name <- substitute(x)))
  attr(value, "name") <- name <- as.character(name)
  assign(name, value, envir = parent.frame())
}

my_env := new.env()

my_env
#> <environment: 0x8909adb98>
#> attr(,"name")
#> [1] "my_env"

my_func := function(a, b) {}

my_func
#> function (a, b) 
#> {
#> }
#> attr(,"name")
#> [1] "my_func"

@lawremi
Copy link
Copy Markdown
Collaborator

lawremi commented Jun 1, 2026

To clarify, I was just exploring some ways that we could make := more generic if we later decide it is necessary, like for base R inclusion. The bind_as() approach is definitely more complicated in cases where the name is required for a valid object. You'll need take the approach described above for class objects, where a prototype is returned and construction is completed by the bind_as() method. I wouldn't go there yet.

@hadley
Copy link
Copy Markdown
Member Author

hadley commented Jun 1, 2026

So we'll use and export := with the assumption there's a path to eventually get it into base R with the same basic interface, but possibly a different (more generic) underlying implementation.

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

Successfully merging this pull request may close these issues.

Use := everywhere in tests

3 participants