I've seen some similar ideas discussed on the forums (notably Polymorphic methods in enums) which are focused around the idea of improving the experience of attaching case
-specific behavior to enum
s without having a totally fragmented implementation. I encountered this issue yet again yesterday and wanted to solicit feedback on some thoughts I had about potential solutions.
Motivation
It is a common pattern to allow a type to take on one of a limited set of values defined ahead of time by the type implementer. In the simplest case, an enum
is well suited to model this behavior. To borrow the example from the linked thread:
enum TrafficLight {
case red
case yellow
case green
}
Since enum
s allow the specification of methods and computed variables, we can easily add simple behavior to this enum
that depends on the current case
:
enum TrafficLight {
case red, yellow, green
var canAdvance: Bool {
switch self {
case .green:
return true
default:
return false
}
}
}
However, this approach doesn't scale super well. Adding a flashingRed
or flashingYellow
state would require us to add additional case
statements to every method and computed variable, and if any of them have default
statements then the compiler will not catch us if we forget to edit one of the methods. This sort of construct also suffers from readability—there's no way for me as a client of the TrafficLight
type to see all of the behavior associated with a specific case
(perhaps we added color
, luminosity
, displayDuration
computed variables as well...). Instead, I have to visit each member and work out where exactly the case
in question falls in the switch
statement.
Existing Solutions
Private init
structs
One solution that exists today is to define TrafficLight
as a struct
with a private initializer:
struct TrafficLight {
let canAdvance: Bool
let color: UIColor
let luminosity: Double
private init(canAdvance: Bool, color: UIColor, luminosity: Double) { ... }
static let red = TrafficLight(canAdvance: false, color: .red, luminosity: 0.9)
static let yellow = TrafficLight(canAdvance: false, color: .yellow, luminosity: 10)
static let green = TrafficLight(canAdvance: true, color: .green, luminosity: 0.75)
}
With a suitably verbose implementation, we can even create methods on TrafficLight
that can reference "self
":
struct TrafficLight {
...
private let _allowsVehicleThrough: (TrafficLight, Vehicle) -> Bool = { _self, vehicle in
return _self.canAdvance
}
func allowsVehicleThrough(_ vehicle: Vehicle) -> Bool {
return self._allowsVehicleThrough(self, vehicle)
}
static let red = TrafficLight(
canAdvance: false,
color: .red,
luminosity: 0.9,
_allowsVehicleThrough: { _, vehicle in
return vehicle.isEmergencyVehicle
}
)
...
}
A struct
can also leverage stored properties, so that each traffic light could have an associated intersectionId
(and static let red = ...
would become static func red(withIntersectionId: Int) -> TrafficLight
), which on an enum
would necessitate adding an associated value to each individual case
.
This solution, however, results in a large amount of boilerplate in order to support the same functionality as the enum
based approach, and requires difficult-to-name private members that make the code difficult to understand, so the readability benefits of specifying instance behavior inline are questionable.
Protocols
When modeling more complex behavior like this, it might make sense to model a TrafficLight
as a protocol
, which guarantees accessors for canAdvance
, color
, etc. Then, implementations would be declared as struct RedLight: TrafficLight
, and appropriate implementations can be provided.
This solution falls short for a few different reasons. It provides no way for the author of TrafficLight
to enforce the fact that TrafficLight
should only be conformed to by a specific set of types (see Constrain protocol usage outside the module). Furthermore, it introduces a conflation of instances and types. Under protocol TrafficLight
, var x = RedLight()
and var y = RedLight()
aren't meaningfully different but could exhibit different behavior, counter to the intention of the author of TrafficLight
. Some of these problems could be mitigated by marking the requirements of TrafficLight
as static, and then accepting arguments of type TrafficLight.Type
everywhere, but this introduces further issues that arise when dealing with metatypes (notably, lack of protocol conformance).
Proposed Solutions
Lower friction for the "switch on self
" pattern
One solution discussed in the thread linked at the beginning of this post is to introduce syntax which would act as an implicit switch
over some portion of a method on an enum
(either self
or the arguments). Under this proposal, we might write:
var canAdvance: Bool {
case .green:
return true
default:
return false
}
This reduces some boilerplate, but ultimately does not solve many of the issues with the switch
-based solution that exists today. The implementation for a specific case
is still split apart the entire type in ways that may be non-trivial to verify, and adding a new case requires touching every existing method.
Explicit instance-level polymorphism
For struct
s
We could also add an explicit syntax for supporting instance-level polymorphism, which allows a type to specify inline that instances may (or must) define implementations for methods as they see fit. This would essentially act as sugar for the struct
based approach mentioned above, with a straw man syntax as something like:
struct TrafficLight {
var canAdvance: Bool
@instanceFunc func allowsVehicleThrough(_ vehicle: Vehicle) -> Bool {
// default implementation
}
private init(canAdvance: Bool, func allowsVehicleThrough: @instanceFunc (Vehicle) -> Bool) {
self.canAdvance = canAdvance
self.allowsVehicleThrough = allowsVehicleThrough
}
static let red = TrafficLight(
canAdvance: false,
allowsVehicleThrough: { vehicle in
// 'self' is available here since TrafficLight accepts an
// @instanceFunc for this parameter, refers to this specific
// instance of TrafficLight.
return self.canAdvance || vehicle.isEmergencyVehicle
}
)
}
For enum
s
Also proposed in the thread linked at the top is some sort of syntax for separating out method implementations by case
. This usually looked something like:
enum TrafficLight {
case red, yellow
// default implementation
var canAdvance: Bool {
return false
}
case green
var canAdvance: Bool where self == .green {
return true
}
}
or
enum TrafficLight {
var canAdvance: Bool // implementation provided by cases
case red {
var canAdvance: Bool { return false }
}
case yellow {
var canAdvance: Bool { return false }
}
case green {
var canAdvance: Bool { return true }
}
}
This solution, like the init
-based one, successfully separates implementation by instance, and in the first case it reuses existing where
syntax in a way that is relatively apparent what it means.
"Closed" protocols
Raised in the link in the Protocols section above, we could allow users to specify a closed protocol:
// module Traffic
closed protocol TrafficLight { ... }
// module MyApp
struct PurpleLight: TrafficLight { // error: 'PurpleLight' cannot conform to 'TrafficLight' because 'TrafficLight' is closed.
...
}
This would allow the compiler to enforce the restriction of "only these specific types may conform to this protocol." This would not enable some features that would be possible with instance-level poly morphism (e.g. it may not be possible to have TrafficLight: Hashable
, depending on use-case).
Other alternatives...?
The existing and proposed solutions are just what came to mind over the course of the couple days. If there are better existing tools to model this type of behavior, or if anyone else has thought of ways that Swift could provide better support for this pattern, I'd love to hear!
Suggested by @Paul_Cantrell, adapting Java syntax:
Something like Kotlin's sealed class, via @GetSwifty: