[Discussion] Change my mind: using `class` in Swift may be a bad idea

Classes are useful in case you need shared mutable state in encapsulated instances, which happens sometimes, not for most of the code, that it's better off using structs, enums, protocols et cetera, but sometimes, and it's nice to have it as a tool in your toolbox. If anything, I'd deprecate the override keyword, which generally increases complexity for no reason, and damages the semantics of your code, but having the option to model shared mutable state is, I think, I good thing.

1 Like

There are just two different things, that are useful in different situations. One is not worse than the other in general, only in specific use cases.

The way I usually describe value semantics is that changes to each variable are isolated from changes made to any other variable. Reference semantics necessarily give that up - you can modify a variable in one part of the application and that state change is visible in another part.

It seems like what you're looking for is to expose "slices" of the class's API to different components - some components may see property setters and methods which change state, while other components should only be able to see APIs which read state (while it is being changed by those other components).

If that is what you're looking for, there are 2 ways to do it in Swift:

  1. Subclassing (as in other OOP languages. See NSString vs NSMutableString in Obj-C, for example).
  2. Protocols.

Protocols are much more flexible (for example, you could expose multiple, overlapping slices of the API), and are what I would recommend:

class Person {
  var firstName: String
  var lastName: String

  func setName(_ wholeName: String) { ... }
}

protocol ReadOnlyPerson: AnyObject {
  var firstName: String { get }
  var lastName: String { get }
}
extension Person: ReadOnlyPerson {}
// Component 1 has access to the full 'Person' API, including mutating.
func doSomething(_ person: Person) {
  person.firstName = "Jim"  // Works.
  person.setName("Alice Williams")  // Works.
}

// Component 2 can only see a limited slice of the 'Person' API.
func doSomethingReadOnly(_ person: ReadOnlyPerson) {
  print(person.firstName) // Works.

  person.firstName = "Jim"  // Error - cannot assign to property: 'firstName' is a get-only property
  person.setName("Alice Williams")  // Error - value of type 'any ReadOnlyPerson' has no member 'setName'
}
2 Likes

In Objective-C there are classes like NSString and NSMutableString (a subclass of the immutable version) whereas in Swift there's only one String type and mutability is transferred from the variable declaration to the struct instance. I think @cjwcommuny is saying that mutability could similarly be transferred to class instances (not the references), perhaps using a new keyword/syntax (although class definitions could probably reuse the mutating keyword).

You can also do interesting things like hiding the true nature of the type you are usingโ€ฆ

protocol Shape
{
  var color: NSColor { get set }
}

enum ShapeType
{
  case square
  case circle
}

class Square : Shape
{
  var color: NSColor
  
  init(color: NSColor)
  {
    self.color = color
  }
}

struct Circle : Shape
{
  var color: NSColor
}

struct ShapeFactory
{
  static func createShape(of type: ShapeType, with color: NSColor) -> Shape
  {
    switch type
   {
      case .square:
        return Square(color: color)
      case .circle:
        return Circle(color: color)
    }
  }
}

// test code

    var square: Shape = ShapeFactory.createShape(of: .square, with: .red)
    
    print("square \(square)")
    
    square.color = .blue
    
    print("square \(square)")
    
    var circle: Shape = ShapeFactory.createShape(of: .circle, with: .blue)
    
    print("circle \(circle)")
    
    circle.color = .red
    
    print("circle \(circle)")

I fear that you're asking for something that inherently makes no sense.

class C { var x = 0 }

let c1 = C()
c1.x = 45   // ๐™“ hypothetically not allowed, like with structs

var c2 = c1 // reference semantics means this isn't a copy of c1's instance 
c2.x = 23

print(c1.x) // 23, oops!
5 Likes

I can imagine a system where you annotate properties and functions as conditionally mutating and where you have a type modifier on class variables, roughly like this:

class C {
    // a `mutable` property is like a `var` on
    // instances created as `mutable`, otherwise
    // it's like a `let` property
    mutable y: Int? = nil
}

let c1 = C()
c1.y = 1 // โŒ type of c is `C`, c.y is immutable

let c2: mutable C = C()
// alternatively: mutable let c2 = C()
c2.y = 1 // OK, type of c is `mutable C`, c.y is mutable

let c3: mutable C = c1 // โŒ value of type `C` cannot be
                       // assigned to a variable of type `mutable C`
let c4: C = c2 // OK, `mutable C` can be promoted to `C`
let c5 = c2    // OK, type of c4 is inferred to be `mutable C`
// c2, c4, and c5 point to the same instance
// but c4 cannot call mutating methods/setters

This would basically allow you to define mutable and immutable versions of a class under one name, elevating the distinction to a general language feature instead of something communicated only through naming and documentation.

I think the main problem, from a syntax perspective, is making a clear/concise distinction between the mutability of the reference and mutability of the instance. It's probably simpler to just use the tools we've got already, i.e. subclassing and protocol slicing (which feels a bit backwards by making the mutable version the default, but still is cool & good to know).

I have used the following pattern on occasion to limit the mutability of class properties:

class Box<Value> {
    private(set) var unboxed: Value

    private init(_ value: Value) {
        self.unboxed = value
    }

    struct Writer {
        init(_ value: Value) {
            box = Box(value)
        }

        let box: Box

        var unboxed: Value {
            get { box.unboxed }
            nonmutating set { box.unboxed = newValue }
        }
    }
}

Here, the only way to create a Box is to create a Box.Writer and ask for the writer's box:

let writer: Box.Writer = Box.Writer(123)
let box: Box = writer.box

Anyone with a Box can unbox its value:

print(box.unboxed)

But only someone with a Box.Writer can modify the boxed value:

writer.unboxed += 1 // ok
box.unboxed += 1 // ๐Ÿ›‘ Left side of mutating operator isn't mutable: 'unboxed' setter is inaccessible

If you want to be able to treat Box and Box.Writer interchangeably, you can use a protocol:

protocol Unboxable {
    associatedtype Value
    var unboxed: Value { get }
}

extension Box: Unboxable { }
extension Box.Writer: Unboxable { }

and then write generically over the protocol:

func generic<B: Unboxable>(_ box: B) where B.Value == Int {
    print(2 * box.unboxed)
}

// or in Swift 5.7, if you declare protocol Unboxable<Value>:

func generic(_ box: some Unboxable<Int>) {
    print(2 * box.unboxed)
}

generic(writer)
generic(box)

One difference between this and Karl's protocol ReadOnlyPerson pattern is that the Box pattern doesn't allow an as cast to recover mutability:

let illicitWriter = box as? Box<Int>.Writer // โš ๏ธ Cast from 'Box<Int>' to unrelated type 'Box<Int>.Writer' always fails

The only ways to mutate the contents of the box are to be given its original Writer or to use an explicitly unsafe operation (like unsafeBitCast) to create a new Writer wrapping it.

1 Like

Oh! I havenโ€™t seen Crusty in any WWDC videos this year. :disappointed:

My thoughts (I don't have time to properly organize them and also I will be simplifying some things, sorry!):

Reference types

  • shared reference types with multiple owners via reference counting - in Swift: class instances, ManagedBufferPointer thingy etc.

    Those objects/instances/values (not sure what the right term would be, maybe something like: memory under the reference) are accessible from multiple places.

    For example:

    • I stored my object in a collection like an Array (or even other object - struct/class/enum whatever)
    • I have reference to this object as a local variable

    Both places fully โ€œownโ€ the value: as long as they are alive the referenced object will not be deallocated. Each owner increments the reference count by 1. In other words: object with a reference count = 5 has 5 owners somewhere in the current snapshot of the running program.

    (Minor note: you can do reference count on -1 where the initial reference does not count, then you deallocate when reference count goes below 0.)

  • shared reference types without any ownership notion - in Swift: all of the UnsafePointer flavours.

    In an atomic program snapshot there can be multiple references to the same object.

    Simple to explain, difficult to use. You are on your own: you (as a programmer) are responsible for managing the lifetime.

  • unique references - in Swift: not available?

    In an atomic program snapshot there can be only a single reference this object. This kind-of goes into move-only (non-copyable) types and I will talk about this later.

Value types

  • value types which contain a reference type (directly on indirectly) - in Swift:

    class Name { var value: String}
    struct Person { let name: Name }
    

    Person is a struct which suggests value semantics.

    The problem is that it stores a property with shared reference semantics (via reference counting). So, while Person can't change (it has the same name for ever and ever) the property that it holds can. Now the philosophical question is: if the name changes -> does this mean that the Person has also changed?

    This kind of means that now our struct has a reference semantics, since changing the name.value in one place also changed it for every copy of this Person.

    Please note that sometimes having some indirection (for example via reference type) is a must! For example if a struct wants to contain itself:

    struct LinkedListNode {
      let value: Int
      var next: LinkedListNode
    }
    

    How big is LinkedListNode? As in: how much space do we need to allocate to hold a single instance of LinkedListNode? Well... we have to look at the properties:

    • value: Int - we know how big those are, probably 64 bit
    • next: LinkedListNode - We don't know! Ooo!

    This is called recursive type (see: "Type Systems" paper by Luca Cardelli from Microsoft for a quick reference).

    Anyway, you can still have value semantics (in Swift sense) if:

    • stored reference type is immutable - in a class this means that all of the properties are let. Remember that this class may store some more reference types in which case all of them have to be immutable (deep immutability).
    • stored reference type is unique - in our Person example: if Name was unique then creating a new copy of Person would violate the uniqueness of Name. Though this topic is a little bit more complicated.
  • value types which contain only value types - boring, lol...

Move-only (non copyable) types

They are awesome, but wrapping your head around them is quite tricky. They are mostly used for RAII.

Previously I mentioned unique references, but never said anything on how one would be able to express it in Swift. Let's go:

struct MemoryAllocation {
  let ptr: UnsafePointer
}

We have MemoryAllocation:

  • it allows for multiple owners for the underlying memory buffer - just pass MemoryAllocation as an argument to a function and it can access the buffer.
  • we as programmers are responsible for managing the lifetime of the buffer (aka. deallocating it when nobody else needs it)

If we want to have shared ownership, but we don't want to manage the deallocation, then we can just add reference count which stores the owner count - if owner count = 0 then deallocate.

If we want to express unique ownership of the buffer then we need move-only types:

struct MemoryAllocation: MoveOnly {
  let ptr: UnsafePointer
  deinit { free(ptr) }
}

Under this mode:

  • there can only be a single instance of MemoryAllocation that has the reference to this ptr (we can't copy it - thats the whole thing)
  • if we pass it as an argument to a method then this method now owns the only existing instance of MemoryAllocation that has this ptr - we can no longer use it in the parent method!
  • there is a thing called borrowing in which a method can get a reference to a move-only type but it deos not acquire ownership (and remember: no ownership -> you can only use it, but you cant store it in Array etc - this would copy it)
  • assign (=) transfers ownership:
    let x = MemoryAllocation(...)
    let y = x
    print(x) // compiler error, 'x' is no longer available!
    
  • you can have deinit on struct because compiler knows that there was only a single reference to this resource, so if the reference goes out of scope -> we can call deinit.

Anyway, move only types allow you to write a smart pointer in Swift - this is kind of blocking me from writing an garbage collection for Violet - Python VM that I wrote.

4 Likes