This video from WWDC 2015 has really been resonating with me recently.
I’ve been transitioning a couple of large projects from class-based, Objective-C code to a more Struct-based, Swift approach. The revelation comes in the form of embracing protocol-oriented design and what that means for the things that I do.
These projects are all music based. They all need to represent music in some way. So I have been writing a Music representation library to unify and codify the things that I need to do. But here’s an example of the embrace of the protocol:
MusicTransposable Protocol
Music pitches can be transposed, or moved, in order to represent other pitches. For example, C0 transposed by a Major Third becomes E0. The MusicTransposable protocol should be adopted by any type that can be transposed by an interval. Default implementations are available for MusicPitch and types that adopt MusicPitchCollection (i.e. MusicChords can be transposed by transposing their individual pitches).
Here’s how it works:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/// Protocol describing an entity that can be transposed. /// /// Structs and classes adopting this protocol need only provide `transposed(by interval: MusicInterval)`, as the protocol will infer that this is the same transposition used in the mutating verion `transpose(by interval: MusicInterval)` public protocol MusicTransposable { /// Transposes an object by a given interval. Mutable version. Must throw an error if transposition is not possible. /// /// - note: The default implementation uses the immutable version as follows: /// /// ````self = try self.transposed(by: interval)```` /// /// /// - Parameter interval: The `MusicInterval` that describes the transposition /// - Throws: Some error to explain why the object was not able to be transposed mutating func transpose(by interval: MusicInterval) throws /// Transposes an object by a given interval. Must throw an error if transposition is not possible. /// /// - Parameter interval: The `MusicInterval` that describes the transposition /// - Returns: A transposed object of the same type /// - Throws: Some error to explain why the object was not able to be transposed func transposed(by interval: MusicInterval) throws -> Self } /// In order to simplify the protocol adoption, extend the protocol such that the mutating function uses the non-mutating version extension MusicTransposable { public mutating func transpose(by interval: MusicInterval) throws { self = try self.transposed(by: interval) } } |
From here, we can extend MusicPitch to adopt the protocol, using MusicInterval’s destinationPitch method to compute a destination pitch from a root pitch:
|
1 2 3 4 5 6 |
///Default implementation for transposing a music pitch. extension MusicPitch: MusicTransposable { public func transposed(by interval: MusicInterval) throws -> MusicPitch { return try interval.destinationPitch(from: self) } } |
But what about MusicPitchCollection, a protocol for managing a collection of pitches, such as a Scale or Chord:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/// Describes a Collection consisting of `MusicPitch` objects. This should also adopt `MusicTransposable`, as the MusicPitch elements can be transposed. internal protocol MusicPitchCollection: Collection, MusicTransposable { var pitches: [MusicPitch] { get set } } /// Extends `MusicPitchCollection` to give a default `Collection` implementation. extension MusicPitchCollection { public var startIndex: Int { return pitches.startIndex } public var endIndex: Int { return pitches.endIndex } public subscript(position: Int) -> MusicPitch { return pitches[position] } public func index(after i: Int) -> Int { return pitches.index(after: i) } } |
Notice that I am requiring MusicPitchCollection adoptees to also adopt MusicTransposable. But since they are simply collections of pitches, which are already transposable, maybe they should just transpose their individual pitches:
|
1 2 3 4 5 6 7 8 |
/// Extends `MusicPitchCollection` to give a default `MusicTransposable` implementation. extension MusicPitchCollection { public func transposed(by interval: MusicInterval) throws -> Self { var newSelf = self newSelf.pitches = try self.pitches.map { try $0.transposed(by: interval) } return newSelf } } |
It should be clear by now that any collection of pitches adopts MusicPitchCollection and immediately is able to transpose those pitches without writing any extra code beyond a declaration of an array to store those pitches. This kind of out-of-the-box functionality is amazing.
The next big step involves rewriting the drawing code that displays notes and rhythms. Stay tuned, because protocol adoption makes this problem much more general and involves a much cleaner codebase that was just not possible before.