Utilizing value semantics in Swift
Basics article available: Value and Reference TypesOne really interesting aspect of Swift’s overall design is how centered it is around the concept of value types. Not only are most of the standard library’s core types (like String
, Array
and Dictionary
) modeled as values, even fundamental language concepts — such as optionals — are represented as values under the hood.
What makes value types unique are the semantics around how they’re passed between the various parts of a program, as well as how mutations are applied to a given instance. This week, let’s take a look at a few different ways in which we can make use of those semantics — and how doing so could significantly improve the flexibility of our value-based code.
Unlocking local mutations
In general, the less mutable state that a program contains, the fewer are the chances for errors to occur. When things are kept immutable, they’re inherently more predictable, since there’s no chance of unexpected changes happening. However, making something immutable also often means sacrificing flexibility, which can sometimes become problematic.
Let’s say that we’re working on an app that deals with videos, and in order to make our core Video
model as predictable as possible, we’ve chosen to make all of its properties immutable by defining them using let
:
struct Video {
let id: UUID
let url: URL
let title: String
let description: String
let tags: Set<Tag>
}
The above may seem like a good idea at first, especially if the only current way to obtain Video
instances are to decode them from data downloaded over the network. However, thanks to the power of value semantics, doing something like the above is most often not necessary.
Not only are structs immutable by default unless they’re stored in a variable that itself is mutable, any mutations made to an instance of a struct will also always just be applied to the local copy of that value. That means that even if we were to open our Video
type up for mutations, we wouldn’t risk introducing bugs caused by unhandled state changes.
So let’s do just that, by using var
to define most of our model’s properties, rather than let
. We won’t, however, make that change to all properties — since we want some of them to always remain constant — such as id
and url
in this case:
struct Video {
let id: UUID
let url: URL
var title: String
var description: String
var tags: Set<Tag>
}
By performing the above change we both make it crystal clear what parts of a Video
model that could potentially change in the future (even if those mutations happen elsewhere, such as on our server), but we also unlock new use cases for that model — such as using it to keep track of local state.
Let’s say that we’re adding a new feature to our video app, which lets our users perform local edits to a video that they’ve previously downloaded. Since we’ve now opened our Video
model up for local mutations, we could simply let such a video editor operate directly on an instance of our model — like this:
class VideoEditingViewController: UIViewController {
private var video: Video
...
func titleTextFieldDidChange(_ textField: UITextField) {
textField.text.map { video.title = $0 }
}
func tagSelectionView(_ view: TagSelectionView,
didAddTagNamed tagName: String) {
video.tags.insert(Tag(name: tagName))
}
}
The beauty of value semantics in the above scenario is that any local mutations that VideoEditingViewController
makes to its private Video
value won’t be propagated elsewhere. That means that we’re free to work on that value in complete isolation, and all of the user’s edits can be kept clearly separated from the original data source.
On the other hand, whenever we do want to keep any Video
instance completely immutable, all we have to do is to reference it using a let
— and no mutations will be allowed:
struct SearchResult {
let video: Video
let matchedQuery: Query
}
When it comes to our core data models, like the above Video
type, letting the enclosing context decide whether to allow mutations or not — rather than baking those decisions into each model — often makes our model code much more flexible, without introducing any substantial risks, all thanks to value semantics.
Ensuring data consistency
However, sometimes we do need to exercise a bit more control when it comes to how a model is allowed to be mutated, especially if different parts of that model are somehow connected or dependent on each other.
For example, let’s now say that we’re working on a shopping app which contains a ShoppingCart
model. Besides storing an array of Product
values that the user has added to their shopping cart, we also store the total price of all products as well as their IDs — to both avoid having to recalculate the total price each time it’s accessed, and to enable constant-time lookup of whether a given product has already been added:
struct ShoppingCart {
var totalPrice: Int
var productIDs: Set<UUID>
var products: [Product]
}
The above setup gives us both great flexibility and great performance, since many of the common operations that we’ll perform on a ShoppingCart
instance can be executed in constant time — however, in this case, making everything mutable has also added a significant risk for our data to become inconsistent.
Not only do we have to always remember to update the totalPrice
and productIDs
properties whenever we add or remove a product, each of those properties could also be mutated at any time — without any change in products. That’s not great, but thankfully there’s a solution that lets us keep using value semantics, but in a slightly more controlled fashion.
Instead of making every property fully mutable, let’s restrict most mutations to only be allowed within the ShoppingCart
type itself, by using the private(set)
access modifier. We’ll then perform those mutations only in direct response to a change in the products
array, using a property observer — like this:
struct ShoppingCart {
private(set) var totalPrice = 0
private(set) var productIDs: Set<UUID> = []
var products: [Product] {
didSet { productsDidChange() }
}
init(products: [Product]) {
self.products = products
// Note how we need to manually call our handling
// method within our initializer, since property
// observers aren't triggered until after a value
// has been fully initialized.
productsDidChange()
}
private mutating func productsDidChange() {
totalPrice = products.reduce(0) { price, product in
price + product.price
}
productIDs = []
products.forEach { productIDs.insert($0.id) }
}
}
With the above change, we’re still making full use of value semantics for our products
property, while now also being able to guarantee complete data consistency throughout our model.
Simplifying repeated mutations
While value semantics give us a ton of benefits in terms of limiting how and where mutations can occur, sometimes those limitations can make certain pieces of code a bit more complex than they need to be.
Here we’re working on a type that lets us model an image rendering pipeline as a series of closure-based operations that get applied to a RenderingContext
struct. Each operation is passed the previous context as input, mutates it, and then returns the updated value as output. Finally, once all operations have been performed, we take the final context value and use it to generate an image:
struct RenderingPipeline {
var operations: [(RenderingContext) -> RenderingContext]
func render() -> Image {
var context = RenderingContext()
context = operations.reduce(context) { context, operation in
operation(context)
}
return context.makeImage()
}
}
For more information about reduce
, check out “Transforming collections in Swift”.
The above works, but there’s a catch. Within each closure operation, we’ll both need to manually copy the current context into a mutable variable, and once we’ve performed our mutations we also need to explicitly return the updated value — like this:
extension RenderingPipeline {
mutating func fill(with color: Color) {
operations.append { context in
var context = context
context.changeFillColor(to: color)
context.fill(rect: Rect(origin: .zero, size: context.size))
return context
}
}
}
The above may not seem like a big deal, and if the number of operations that we can perform are kept to a minimum, it probably won’t be. However, always having to copy and return the current rendering context does require us to write a fair amount of boilerplate, so let’s see if we can do something about that.
While we want to let RenderingContext
remain a struct, let’s actually pass it by reference rather than by value when calling each operation — using the inout
keyword. That way we can simply keep mutating the same context value all throughout our pipeline:
struct RenderingPipeline {
var operations: [(inout RenderingContext) -> Void]
func render() -> Image {
var context = RenderingContext()
operations.forEach { $0(&context) }
return context.makeImage()
}
}
Using the inout
keyword doesn’t actually pass a pointer to our value, but rather gives us the same top-level behavior as when using a reference type, by automatically creating a mutable copy and assigning the resulting value back to the variable that was passed in.
Not only does the above change make our RenderingPipeline
type much simpler, it also now lets us mutate each context directly within our operations — no copying or returning of values required:
extension RenderingPipeline {
mutating func fill(with color: Color) {
operations.append { context in
context.changeFillColor(to: color)
context.fill(rect: Rect(origin: .zero, size: context.size))
}
}
}
While the inout
keyword should definitely be used with some amount of caution, since it does circumvent some of the safeguards that value types give us in terms of avoiding shared state — when used internally within a type, like we did above, it can again give us some very real benefits without much added risk.
Conclusion
Fully utilizing value types in terms of how they handle mutations, and how they ensure that state is kept local by default, can be a great way to improve both the stability and flexibility of our model code.
However, making everything mutable by default isn’t necessarily the best approach — sometimes we do need to lock things down a bit both to ensure data consistency, and to send a clear signal as to what data that’s never supposed to be modified.
Finally, using the inout
keyword can let us keep leveraging the power of value types — while also introducing some of the convenience of reference types, when used in the right situations.
What do you think? How do you pick between using a value type and a reference type, and will any of the techniques from this article help you make your model code more flexible? Let me know, either via Twitter or email.
Thanks for reading! 🚀