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.
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:
- Subclassing (as in other OOP languages. See
NSString
vsNSMutableString
in Obj-C, for example). - 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'
}
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!
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.
Oh! I havenโt seen Crusty in any WWDC videos this year.
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.)
- I stored my object in a collection like an
-
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 astruct
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 samename
for ever and ever) the property that it holds can. Now the philosophical question is: if thename
changes -> does this mean that thePerson
has also changed?This kind of means that now our
struct
has a reference semantics, since changing thename.value
in one place also changed it for every copy of thisPerson
.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 ofLinkedListNode
? 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 arelet
. Remember that thisclass
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: ifName
was unique then creating a new copy ofPerson
would violate the uniqueness ofName
. 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 thisptr
(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 thisptr
- 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 inArray
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
onstruct
because compiler knows that there was only a single reference to this resource, so if the reference goes out of scope -> we can calldeinit
.
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.