Mutating methods on classes

Why are mutating methods disallowed on classes?

Ordinarily, when we need to modify a class instance we can just perform the changes we want. And if we often perform the same sequence of modifications, we can encapsulate them into an instance method.

However, when we need to assign a new value to the variable itself, we can still do so directly, but not in an instance method.

If we need to encapsulate some behavior which includes reassigning self, we can do so easily on a struct by creating a mutating method, but we have to jump through hoops to do the same thing on a class.

We can, of course, write a static or global function taking a class instance inout, and assign to it there. We can even conform the class to a protocol with a mutating extension method. But we can’t create a mutating instance method on the class itself.

• • •

For example, let’s say we have a class A and its subclass B. We can assign a value of type B to a variable of type A, because of the subclass relationship. Now suppose we have some logic whereby we need to perform such an assignment. That is, a variable of type A needs to be assigned a value of type B.

There may be additional logic involved, so we wish to encapsulate it into a method. But the obvious approach doesn’t work:

class A {}
class B: A {}

extension A {
  // error: 'mutating' isn't valid on methods in classes or class-bound protocols
  mutating func becomeB() {
    // error: cannot assign to value: 'self' is immutable
    self = B()
  }
}

If we remove mutating then the second error remains. We cannot reassign self in an instance method on A.

• • •

The behavior we want is still possible to achieve, but it’s more complicated and non-obvious:

protocol P {}

extension A: P {}

extension P where Self == A {
  mutating func becomeB() {
    self = B()
  }
}

This works, and it lets us do things like:

var a = A()
type(of: a)    // A

a.becomeB()
type(of: a)    // B

• • •

So we can, in fact, achieve a mutating method on a class instance, but instead of writing our logic directly in a function defined on the class, we had to create an empty protocol, conform the class to it, and place the mutating method in a same-type-constrained extension.

Why can’t we just create mutating methods on classes to begin with?

2 Likes

What would happen if you conform your class to a protocol that has mutating methods as control points, or even mutating default implementations? I would say that in general you want your implementation on classes to be non-mutating. Or do you want the user to decide how he/she would implement a mutating protocol requirement?

protocol P {
  mutating func foo()
}

class A: P {
  func foo() {
    print("foo")
  }
}

let a = A()
a.foo() 

func test(_ p: P) {
  var copy = p
  copy.foo()
}

test(a) // prints "foo"

Edit: Okay I replied too fast. The compiler already allows this:

struct S: P {
  func foo() {
    print("bar")
  }
}

let s = S()
s.foo()

Then I don't see a reason why we can't lift that restriction.

Instead of becomeB, consider the consequences for a similar becomeA case when called on a B, both in the disallowed class version and the allowed protocol version.

1 Like

Ah, very nice. To elucidate what Ben is indicating:

The protocol extension is constrained so the method is only available when the instance is exactly of type A. It is not available for subclasses.

If instead we allowed the declaration of a mutating instance method on a class and that method was inherited by subclasses, then we could call becomeA on an instance of type B, which is not type-safe.

So, if we were to allow mutating members in classes, they could not be inherited.

Edit: or rather, it could only be allowed to call them on a variable of the same static type. So the following would be fine:

var b: A = B()
b.becomeA()

But this would not:

var b: B = B()
b.becomeA()

And indeed that is how the protocol extension method availability works currently.

You might be interested in learning about SmallTalk's batshit crazy become: method (emphasis mine):

become: otherObject

Change all references to the receiver into references to otherObject. Depending on the implementation, references to otherObject might or might not be transformed into the receiver (respectively, ’two-way become’ and ’one-way become’). Implementations doing one-way become answer the receiver (so that it is not lost). Most implementations doing two-way become answer otherObject, but this is not assured - so do answer the receiver for consistency. GNU Smalltalk does two-way become and answers otherObject, but this might change in future versions: programs should not rely on the behavior and results of #become: [what the fuck?].

Just think about what that implies. Every reference, globally in the system, to one object, becomes a reference to a new object. There's no enforcement that the new object is the same class, supports the same protocols, or even supports any of the same methods. I honestly don't know how you can wield this powerful tool without cutting yourself. It just introduces a new dimension you have to reason in your code "what if between here and there, another thread entirely replaces this object". Have fun! :stuck_out_tongue:

1 Like

Mutating would open the door to copy-on-write classes that do not depend on a wrapper struct:

class Counter {
  var value = 0
  mutating func increment() {
    if !isKnownUniquelyReferenced(&self) {
      self = clone()
    }
    value += 1
  }
}

COW will have issues however if you introduce subclasses that aren't adequately checking for uniqueness. So mutating should probably be restricted to final classes.

Things become weird with setters though. Typically a setter is mutating, but when inside a class it isn't. So in our COW class they would have to be explicitly marked mutating. That's a bit strange and easy to forget. And don't you dare expose a stored property directly!

This makes me think we probably should go for a special kind of class to enable use cases like this instead of allowing features to mix in dangerous ways.

1 Like

mutating methods on classes wouldn't be Smalltalk's #become:; they'd just allow replacing the one reference that you call them on. As Nevin pointed out, you can already contrive them into existence with protocol extensions—even more generally than what's been shown, actually:

protocol Resettable {
  init()
}

extension Resettable {
  mutating func reset() {
    self = type(of: self).init()
  }
}

class A: Resettable, CustomDebugStringConvertible {
  required convenience init() { self.init(value: 0) }
  init(value: Int) { self.value = value }
  var value: Int
  var debugDescription: String { return "\(type(of: self)): \(self.value)" }
}
class B: A {}

var a: A = B(value: 1)
let orig = a
debugPrint(a)
a.reset()
debugPrint(a)
debugPrint(orig)

Anyway, I think we just didn't allow this because it would be confusing, and give the impression that classes had some form of const-correctness like in C++, which they don't.

(I'm in the camp that says classes probably should never have been allowed to satisfy protocols with mutating requirements to begin with, though that has other problems that would need to be worked out. It's too late for that, though.)

2 Likes