Data Classes on Swift
TL;DR: How do I get that?
While working on the latest project at work, I had the chance to work on a project where we tried some similar but different design patterns for our Android and iOS apps:
- iOS app, written mainly on Swift: MVVM was used, making everything reactive and subscribing to events emitted by the observables in the ViewModel.
- Android app, using only Kotlin: MVI with a state reducer was used on Android, so the view observed a single Observable which relayed the current state to render.
After using the 2 architectures extensively across both apps I ended up liking MVI and its rendering of immutable states a lot more than MVVM.
Note: the code has been simplified to show in the blog post.
iOS approach:
class MyController: UIViewController {
func observe() {
viewModel.userObservable.observeNotNil { user in
self.nameLabel.text = user.name
}
viewModel.contactsObservable.observeNotNil { contacts in
self.contacts = contacts
self.tableView.reload()
}
// Continue for n more observable values
...
}
func loadData() {
viewModel.loadUser()
viewModel.loadContacts()
}
}
Android approach:
class MyController: Controller { // Conductor used here
fun observe() {
presenter.state
.mapNotNull { it.user }
.distinctUntilChanged()
.subscribeWithView ({ user -> renderUser(user) })
presenter.state
.distinctUntilChanged()
.subscribeWithView ({ user -> renderContacts(contacts) })
// Continue for n more observable values
...
}
fun loadData() {
presenter.loadUser()
presenter.loadContacts()
}
}
I guess this is probably a matter or personal taste, but having everything inside a single State made the code a lot easier to read and predict for me since you could actually read the current state of the view to be rendered.
State was stored in a data class, so creating a new mutated copy was as easy as:
state = state.copy(user = newUser)
I liked the idea so much that I tried to apply it to our Swift code too, as a proof of concept. However, the state immutable struct was a lot more tedious to copy:
state = State(user: newUser,
contacts: state.contact,
loading: state.loading,
...,
error: state.error)
Every single value from the old state had to be passed again to the State struct's initializer. So I thought: wouldn't it be great to have data classes and copy on Swift too?
data class
internals
To try to figure out how to actually code a Swift version of a data class, first I had to figure out how it's actually implemented on Kotlin. So I took one of our data classes and decompiled it to see its implementation in Java code. To do that, I simply opened a data class, used Tools > Kotlin > Show Kotlin Bytecode and then selected Decompile.
class State {
private final User user;
private final List<User> contacts;
@NotNull
public State copy(User var1, List<User> var2, ...) {
return new State(var1, var2, ...);
}
@NotNull
public static State copy$default(
State previousState,
User var1,
List<User> var2,
CustomError var3,
...,
int flag,
Object varN) {
if ((flag & 1) != 0) var1 = previousState.user;
if ((flag & 2) != 0) var2 = previousState.contacts;
if ((flag & 4) != 0) var3 = previousState.error;
// This will continue with (flag & (1 << i)) != 0
...
return previousState.copy(var1, var2, var3, ...);
}
}
From what I saw, I infered that what happens when you call copy(value=...)
on Kotlin is the next:
- Instead of calling
copy(a, b, c)
method directly,copy$default(previousState, a, b, c, flag, varN)
will be called. - The
flag
variable is used as a bitmask to know which values are new and which must be copied frompreviousState
- oddly, a true value means the value will be copied, insted of marking that the value was passed. - Now
copy(a, b, c)
is called, with both the new and the old values as needed.
Also, no, I don't know what the varN
value is used for. The generated code never used it and I couldn't find anything on the docs or on Kotlin's generateCopyFunction method. If you do know please ping me, I'm curious about this!
You might be thinking: why use the flag
arg to mark which values must be copied instead of just passing a null
value?
Well, you might want to actually set one of the properties of the data class to null
, so if you do pass a null
value then the function would have no way to tell what you meant by doing this. So the flag
arg is actually needed.
So, to sum up:
copy
method is generated at build time for each data class.- We need a way to only pass some args, not all of them when
copy
is called. - Select which values will be changed and which copied.
One step at a time
Now that we figured out how copy
works, let's try to find out how to achieve those on Swift.
Generating a copy
method on build time
Oh, boy. Swift is one of the worst languages when you want to do some reflection or some kind of metaprogramming. It heavily relies on ObjC Runtime to do this, which means structs
can't be used.
Luckily, there's an awesome project called Sourcery - awesome name, by the way - that allows us to generate code using templates written in several languages, including Swift.
Passing only some args to that method
Well, we also hit a wall here. Swift doesn't have Kotlin's named arguments, which allow you to pass any arg by just writing:
myFunc(argName = value)
However, when you use default arguments on Swift you can achieve something quite close:
func defaultArgsFunc(a: Int = 0, b: Int = 1, c: Int = -1) { ... }
// All args
defaultArgsFunc(a: 1, b: 2, c: 3)
// Only first
defaultArgsFunc(a: 1)
// Omit last one
defaultArgsFunc(a: 1, b: 2)
// Omit second one
defaultArgsFunc(a: 1, c: 2)
// Only second
defaultArgsFunc(b: 1)
Actually, any combination of args passed that follows the natural order - even if there are gaps - can be used:
// Is fine
defaultArgsFunc(a: 1, c: 2)
// ERROR! This won't build, as 'a' must always be passed before 'c'
defaultArgsFunc(c: 1, a: 2)
So we got this covered - with a little restriction.
Select which values will be changed and which will be copied
Ok, this is probably the trickiest part. We don't have any way to modify the built code to make our copy(a = value)
call some copy$default(a: value, flag)
function. So... what do we do now?
We could always use default args that = nil
, like this:
struct DataClassExample {
func copy(a: Int? = nil, b: Int? = nil, c: Int? = nil) -> DataClassExample {
...
}
}
But we face the same issue as in Kotlin: how would I actually pass a nil
value?
After giving it some thought, I though enums would be a great solution here:
enum CopyValue<T> {
case new(T)
case same
case `nil`
}
struct DataClassExample {
func copy(a: CopyValue<Int> = .same,
b: CopyValue<Int> = .same,
c: CopyValue<Int> = .same) -> DataClassExample {
let new_a: Int?
switch (a) {
case let .new(value):
// Use new value
new_a = value
case .nil:
// Actually use nil
new_a = nil
case .same:
// Copy value
new_a = self.a
}
// And so on with the rest ...
return DataClassExample(a: new_a, b: new_b, c: new_c)
}
}
// Yay!
let copiedDataClass = myDataClass.copy(a = .new(1), c = .nil)
That looks almost usable! However, there are some values that just shouldn't be allowed to be set to nil
and the compiler will stop if you try to generate code that forces this, so we might want to refactor that to:
// To use with Optional values
enum OptionalCopyValue<T> {
case new(T)
case same
case `nil`
}
// Use with non-optional values
enum CopyValue<T> {
case new(T)
case same
}
Putting it all together
I ended up with this template I called DataStruct.swifttemplate
:
<% for type in types.structs where type.inheritedTypes.contains("DataStruct") { -%>
<%_ -%>
// Copyable extension for <%= type.name %>
public extension <%= type.name %> {
public func copy(
<% for variable in type.storedVariables { -%>
<%_ if variable.typeName.isOptional { -%>
<%= variable.name %> copied_<%= variable.name %>: OptionalCopyValue<<%= variable.unwrappedTypeName %>> = .same<% -%>
<%_ } else { -%>
<%= variable.name %> copied_<%= variable.name %>: CopyValue<<%= variable.typeName %>> = .same<% -%>
<%_ } -%>
<%_ if variable != type.storedVariables.last { %>, <% } %>
<% } -%>
) -> <%= type.name %> {
<% for variable in type.storedVariables { -%>
<%_ if variable.typeName.isOptional { -%>
<%_ %>let <%= variable.name %>: <%= variable.typeName %> = setValueOptional(copied_<%= variable.name %>, self.<%= variable.name %>)
<%_ } else { -%>
<%_ %>let <%= variable.name %>: <%= variable.typeName %> = setValue(copied_<%= variable.name %>, self.<%= variable.name %>)
<%_ } -%>
<% } -%>
<%_ %>return <%= type.name %>(
<% for variable in type.storedVariables { -%>
<%= variable.name %>: <%= variable.name -%>
<%_ if variable != type.storedVariables.last { %>, <% } _%>
<% } -%>
)
}
}
Hideous, huh? Well, it does serve its purpose, so don't be too picky. Hopefully you won't ever have to edit it.
And I used this protocol
and extension
for the auxiliar functions:
public protocol DataStruct {}
public extension DataStruct {
func setValueOptional<T>(_ value: OptionalCopyValue<T>, _ defaultValue: T?) -> T? {
switch(value) {
case let .new(content):
return content
case .same:
return defaultValue
default:
return nil
}
}
func setValue<T>(_ value: CopyValue<T>, _ defaultValue: T) -> T {
switch(value) {
case let .new(content):
return content
case .same:
return defaultValue
}
}
}
public enum OptionalCopyValue<T> {
case new(T)
case same
case `nil`
}
public enum CopyValue<T> {
case new(T)
case same
}
To get this working you might want to take a look at Sourcery's GH repo, but to sum up, you'd have to:
- Get a copy of Sourcery, either by downloading the binary, installing with brew or using Cocoapods.
- Add a build phase to execute Sourcery - or execute manually, if you prefer it.
- Either use command line args or a
sourcery.yml
file like this:
sources:
- Your/Sources/Path
templates:
- Your/Templates/Path
output:
path: Generated/Code/Path
Also, don't forget to add those generated files to the sources to use on the build process.
- Implement
DataStruct
protocol on any struct you want to add thecopy
method to:
struct State: DataStruct {
let user: User?
let contacts: [User]
...
}
This will generate:
extension State {
func copy(user: OptionalCopyValue<User> = .same,
contacts: CopyValue<[User]> = .same,
...) -> State {
let copied_user = setValueOptional(user, self.user)
let copied_contacts = setValue(contacts, self.contacts)
...
return State(user: copied_user, contacts: copied_contacts, ...)
}
}
Conclusion
We can actually have the equivalent of data class
on Swift with a bit of hacking, so using immutable structs shouldn't be such a nuisance now.
However, I know this setup is still a bit hard, so I'm working on making a pod which include this as well as autogenerated equality and hashValue.
I hope you find this useful!