DEV Community

Kevin Cox
Kevin Cox

Posted on • Originally published at kevincox.ca on

The Missing Call-Assign Operator

Let’s say you have a type for a Cartesian coordinate.

struct Point { x: f64, y: f64 }

Now we want to add a method to rotate it. There are two common patterns here:

impl Point {
    // Return a new point, don't modify the self parameter.
    fn rotate_new(&self, rad: f64) -> Point;

    // Modify self.
    fn rotate_self(&mut self, rad: f64);
}

Both of these are quite reasonable, but they are both inconvenient in some situations.

For example with the rotate_new variant:

// Creating a new value is easy.
turn_to(angle.rotate_new(amount));

// But modifying a variable creates duplication.
angle = angle.rotate_new(amount);

And for the rotate_self variant:

// Creating a new value is very annoying.
let mut target = angle.clone();
target.rotate_self(amount);
turn_to(target);

// But modifying a variable is natural.
angle.rotate_self(amount);

You can say that mutation is evil so rotate_new is better but in a language that isn’t purely functional you will want to mutate some things. Additionally, in some cases the extra copying can be prohibitively expensive.

So how do we define an interface that satisfies all use cases? The simplest solution is to define both like we did above. However, probably with a better naming scheme. How about rotate and rotate_new, rotate and with_rotation? Or we can get cute and call them rotate and rotated? This works, but it requires lots of repetitive code in the interface. Furthermore, the lack of strong naming convention makes users of the library guess what the methods are called.

Are there any other options? It turns out that programming languages have solved this already! Think about the + operator.

turn(angle + adjustment);
angle += adjustment;

Ok, not just the + operator, but also the += operator. With these two we can handle both cases naturally! Luckily for us, it isn’t just + that has +=. Depending on your language you probably have op-assign operators for most operators in your language! -=, <<=, ||=

But it is (usually) a fixed list of operators with implied meaning. Your users probably wouldn’t appreciate if you overloaded << to rotate a point just because you wanted to take advantage of <<=!

What if we could have an op-assign operator for function calls? A call-assign operator. The syntax would need to be a little different because we need to put a function name in there, but I think we can find something that doesn’t look too weird.

angle.=rotate(amount);
angle .= rotate(amount);
angle.rotate=(amount);

Take your pick. While .= most closely resembles the other op-assign operators, I like thing.method=(...) the most. However in many languages it assigns a parenthesized expression to a property already, so it may not be the best choice overall.

One place where I think this could come in handy is the builder pattern. The builder pattern is a way to emulate keyword and optional arguments in languages that don’t have them.

let http = reqwest::Client::builder()
    .connect_timeout(std::time::Duration::from_secs(1))
    .timeout(std::time::Duration::from_secs(10))
    .build()?;

Personally, I find this pattern annoying. It works well in the “happy path” but falls apart if you need to sometimes set an option.

let mut builder = reqwest::Client::builder()
    .connect_timeout(std::time::Duration::from_secs(1))
    .timeout(std::time::Duration::from_secs(10));
if cfg.use_rustls {
    builder = builder.use_rustls_tls();
}
let http = builder.build()?;

Once you run into something like this—where you have a conditional or want to delegate some options to another function—I find that the simple mutable approach (&mut self)ends up simpler and more consistent.

// Assuming the API was changed.
let mut builder = reqwest::Client::builder();
builder.connect_timeout(std::time::Duration::from_secs(1))
builder.timeout(std::time::Duration::from_secs(10));
if cfg.use_rustls {
    builder.use_rustls_tls();
}
configure_client(&mut builder);
let http = builder.build()?;

I think call-assign makes this work well with the existing API. This means that users of the “happy path” can continue to use chaining if they prefer. But people like me can use simple assignments.

// Assuming the API was changed.
let mut builder = reqwest::Client::builder();
builder.connect_timeout=(std::time::Duration::from_secs(1))
builder.timeout=(std::time::Duration::from_secs(10));
if cfg.use_rustls {
    builder.use_rustls_tls=();
}
configure_client(&mut builder);
let http = builder.build()?;

(For performance reasons it would make sense to be able to overload call-assign so that it can be implemented more efficiently. But that is just an optimization.)

Are there any languages that support something like this? Do they have mechanisms for optimizing the implementations of call and call-assign separately for where it matters?

Discussion (0)