This article shows how to use protocol-oriented programming in Swift to design applications by thinking about abstraction in terms of what a domain entity or concept can do rather than what it is. Swift extensions enable programmers to retroactively introduce abstractions not only to enable new use cases for existing concrete types but also to refactor the concrete types themselves. This means abstractions can be introduced at any point in the development cycle and developers don’t need to lock themselves into inflexible abstraction hierarchies at an early stage.
We work through a running example to show how protocol extensions, protocol composition, and protocol inheritance can be used to create traits. These traits can be applied to existing concrete types and can also be used to create new concrete types that get the desired functionality simply by conforming to the corresponding protocols while retaining the flexibility to override default implementations provided by a protocol extension.
Contents
Protocol conformance vs. inheritance
What is protocol-oriented programming
Example: College courses and fees
Beginning retroactive modeling
Protocol extensions
Constrained protocol extensions
Protocol composition
Protocol inheritance
Protocols as traits
Overriding default implementations
Conclusion
What is a protocol
A protocol is a set of requirements, which may include methods, initializers, subscripts, and properties that conforming types must implement. It establishes a contract and provides assurance to users of the protocol that conforming types will meet the requirements of the contract. This means that wherever a protocol type is required, a concrete type conforming to the protocol can be used.
Protocol conformance vs. inheritance
Coming from an object-oriented background to Swift, it is easy to see protocols as just a means for mimicking inheritance for value types. It is important to understand, however, how protocol conformance in Swift differs from inheritance to start to unlock the potential of the protocol-oriented paradigm.
Inheritance is often thought of as an ‘is a’ relationship between entities or concepts in the domain. Horse inherits from Animal because a horse is an animal. Inheritance represents a structural relationship between classes that is established when a class is defined. In Swift, a class can inherit only from one other class and, once it does, it gets inextricably bound to that inheritance hierarchy.
A protocol, on the other hand, can be thought of as an ‘ acts as a’ relationship between concrete entities or concepts and an abstraction. A protocol can bring together otherwise disparate entities or concepts into a role or a persona by virtue of these entities or concepts being able to perform that role or adopt that persona. In Swift, protocol conformance can be used to give an entity as many roles or personas as required by the needs of the application. None of these roles needs to represent a structural relationship and none of them needs to be specified when the type representing the entity is defined.
What is protocol-oriented programming
Protocol-oriented programming is new way of designing software applications, with protocols used to create abstractions and protocol extensions to provide implementation of functionality shared between conforming types. It is important to understand that this differs in fundamental ways from object-oriented programming. Protocol-oriented programming enables designing applications in a truly incremental manner, freeing developers from having to use class inheritance to create abstractions early in the design process, thus avoiding inflexible structural hierarchies of relationships between entities and concepts in the domain.
Where protocol-oriented programming with Swift really shines is in the ability to use type extensions to enable retroactive modeling, which allows a programmer to add new functionality to an existing type without having to modify the original type definition. What this means in practice is that the development process can start largely with concrete types, giving the programmer the freedom to introduce abstractions in the form of protocols at any time in the development process as and when an entity or a concept, or a group of entities or concepts, needs to perform a given role or adopt a given persona in the context of the application. Concrete types can be made to adopt these abstractions simply by extending the concrete types to conform to the relevant protocols, and refactoring common code using protocol extensions.
Example: College courses and fees
Let’s say we are designing an application to calculate fees to be paid by students studying in a college. With classic object-oriented thinking, a likely starting point may be with an abstract class we may call Student, which would be the base class from which concrete classes representing various types of students would inherit. With Swift protocols, however, there is no need to start with an abstraction or even a class. We start by modeling the various types of students using concrete types implemented as structs, deferring any decision about what abstraction may be appropriate for the application to a later stage, when we have implemented a sizable chunk of the required functionality and we have a much better handle on how various parts of the application work together.
To keep the example simple, let’s say the college has two types of students – regular students who are enrolled in the college, and casual students who may take courses as and when they like. We would like each student instance to keep track of the courses the student is currently taking and to use that to calculate fees payable by the student, using a fee per course credit. This applies to both regular and casual students. In addition, regular students pay a fixed enrollment fee at each payment cycle which entitles them to a lower per-course fee and exclusive access to campus facilities.
Before we start modeling students, let’s define a simple Course
type to represent courses that students will take.
struct Course: Hashable {
let name: String
let credits: Decimal
}
Next we implement the the simplest representation of a regular student, where each student has a unique id, a name, and a collection of courses currently being taken, with methods to add and remove courses from this collection. Note the collection is implemented using the Set
type, which represents an unordered collection of unique elements. This ensures that a course that exists in the collection cannot be added again without the need for us to add this check in our code. The Swift Set
type requires that elements conform to the Hashable
protocol. This is why we added Hashable
compliance to the Course
type we defined above.
Shown below is the initial implementation of the RegularStudent
type. Note the conformance with the Idenfitiable
protocol from the Standard Library, which is used when a value type represents a domain entity that needs a stable notion of identity.
struct RegularStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
}
We now need to think about how students will pay for courses. We start by defining a Fee
type, with a category and an amount. The former is of type Category
, an enum defined within the Fee
type. We use a Money
type to represent monetary values. In a real-world application, this would allow us to represent values and currencies, and potentially enable conversions between currencies. To keep things simple here, we create Money
as a typealias
for the Decimal
type.
typealias Money = Decimal
Here is the fee type, which lets us model enrollment fees and course fees. Note the course
case has a String
associated value, which will be used to store the name of the course to which the fee pertains.
struct Fee: Equatable {
enum Category: Equatable {
case enrollment
case course(String)
}
let category: Category
let amount: Money
}
Next, we create a type to represent an invoice, which each RegularStudent
instance should be able to generate. Given below is a simple Invoice
type, with an array of line items and an auto-calculated payable amount.
struct Invoice: Identifiable {
struct LineItem: Equatable {
let description: String
let amount: Money
}
let id = UUID()
let billTo: String
let lineItems: [LineItem]
var payableAmount: Money {
lineItems.reduce(0) { runningTotal, lineItem in
runningTotal + lineItem.amount
}
}
}
We will give a regular student the ability to generate an invoice based on the courses currently taken. We will do this in a few steps.
First, we create a new Config
type, which will store constant Money
values to be used to calculate enrollment and course fees for a regular student. We do this instead of adding these to the RegularStudent
type to make it easy to change these values without having to modify the RegularStudent
type. It is good practice to keep literal values in one place rather than sprinkling them throughout the code, which can make them hard to find and modify later. This way, we can also indicate which of the literal values are constants and which are configurable, by using type names such as Constants
and Config
respectively.
Here is the initial implementation of the Config
type. We will add more properties to this type as we work through the example.
final class Config {
static let enrollmentFee: Money = 500
static let courseFeeForEnrolledStudents: Money = 200
}
Note that we have marked the Config
class as final
, which means it cannot be subclassed. While this is not required, it is generally good practice to mark classes that will not be used in inheritance as final
to make this intention clear.
Next, we add the following methods to the RegularStudent
type, to give each student the ability to calculate fees for the courses currently taken and generate an invoice.
func calculateFees() -> [Fee] {
var fees = [Fee]()
let enrollmentFee = Fee(
category: .enrollment,
amount: Config.enrollmentFee)
let courseFees = courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForEnrolledStudents)
}
fees.append(enrollmentFee)
fees.append(contentsOf: courseFees)
return fees
}
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
There is a lot going on in the RegularStudent
type and we can already see potential opportunities for abstraction. But we suspend judgement for the time being and define another concrete type to model casual students.
As noted when introducing this example, casual students pay a higher fee per course credit, so we first add the following property to the Config
type.
static let courseFeeForNonEnrolledStudents: Money = 300
Here is the CasualStudent
type, which is largely the same as the RegularStudent
type. The only difference is a change in the calculateFees()
method to only include course fees and use the course fee applicable to casual students for each course currently taken by the student.
struct CasualStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
func calculateFees() -> [Fee] {
courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForNonEnrolledStudents)
}
}
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
Now that we have a couple of concrete types, we can begin to think not only about ways in which we may need to use these types but also how we can refactor their implementations. This is where we start to introduce appropriate abstractions through retroactive modeling.
Beginning retroactive modeling
Let’s say that the only use case we currently have for the student types is to generate invoices for a group of students regardless of whether they are regular or casual. For this, we need an abstraction to bind the student types together so invoices can be generated in a single operation. We will do this with a protocol but, rather than using a protocol to create an abstraction for what these domain entities are, we create an abstraction for what these domain entities can do in the given context, i.e., they can be invoiced. Let’s call this protocol Invoicable
, with a single method that generates an invoice.
protocol Invoicable {
func generateInvoice() -> Invoice
}
Since both our concrete types already have the generateInvoice()
method with the same signature, we simply extend the concrete types to conform to the Invoicable
protocol.
extension RegularStudent: Invoicable {}
extension CasualStudent: Invoicable {}
To test the student types, we write a simple function to print invoices, as shown below.
func printInvoice(_ invoice: Invoice) {
print("* * Invoice * *")
print()
print("Bill to: \(invoice.billTo)")
print()
guard invoice.payableAmount != 0 else {
print("No fee payable")
return
}
for lineItem in invoice.lineItems {
print("\(lineItem.description): \(lineItem.amount)")
}
print("Total payable: \(invoice.payableAmount)")
}
To test whether the student types can generate invoices correctly, we first create the following course instances.
let swift101 = Course(name: "Swift 101", credits: 3)
let dataStruct102 = Course(name: "Data structures 102", credits: 4)
Next, we create a regular student and a casual student, and make them take some of the above courses.
var regularStudent = RegularStudent(fullName: "Maggie Smith")
regularStudent.takeCourse(swift101)
regularStudent.takeCourse(dataStruct102)
var casualStudent = CasualStudent(fullName: "John Rogers")
casualStudent.takeCourse(swift101)
Now we can generate invoices for these students. Note that since both the concrete types conform to the Invoicable
protocol, we create an array of the protocol type, add the concrete types to this array, and use it to generate all the invoice in one operation. The key point here is that the Invoicable
protocol does not give any indication of, nor does it depend on, what the underlying concrete types represent. They could model students, suppliers, or any other domain entity or concept. What brings them together in this context is their ability to generate an invoice.
var invoicables: [Invoicable] = [regularStudent, casualStudent]
for invoicable in invoicables {
printInvoice(invoicable.generateInvoice())
print()
}
// * * Invoice * *
// Bill to: Maggie Smith
//
// Enrollment fee: 500
// Fee for course 'Data structures 102': 800
// Fee for course 'Swift 101': 600
// Total payable: 1900
//
// * * Invoice * *
//
// Bill to: John Rogers
//
// Fee for course 'Swift 101': 900
// Total payable: 900
As expected, the regular student has to pay enrollment fee in addition to fees for courses while the casual student only pays course fees but pays a higher fee per course credit. Next, we see what happens when the casual student completes the one course he has taken.
casualStudent.completeCourse(swift101)
invoicables = [regularStudent, casualStudent]
for invoicable in invoicables {
printInvoice(invoicable.generateInvoice())
print()
}
// * * Invoice * *
//
// Bill to: Maggie Smith
//
// Enrollment fee: 500
// Fee for course 'Data structures 102': 800
// Fee for course 'Swift 101': 600
// Total payable: 1900
//
// * * Invoice * *
//
// Bill to: John Rogers
//
// No fee payable
Having established that we can use our concrete types as intended, we turn our attention to refactoring the concrete types themselves. This is where protocol extensions come into play.
Protocol extensions
Protocol extensions provide implementations to conforming types for methods, initializers, subscripts, and computed properties. The most common use of protocol extensions is to implement some or all of the requirements of a protocol to provide default implementations to conforming types. This makes protocols really powerful in that we can use them not only to specify requirements that conforming types must implement but also to extend them to actually implement these requirements where appropriate. Conforming types still have the option of providing their own implementations to override the default implementations provided by protocol extensions.
Protocol extensions can also be used to make available to conforming types functionality not required by the protocol. This enables protocol extensions to contain methods or computed properties that may be needed to support implementations of the methods or properties required by the protocol. This keeps all related code in one place, avoiding code duplication in conforming types without the need to create helper types or global functions as a means of factoring out common code.
Continuing our running example, we start to refactor the concrete types we have used to model regular and casual students by fleshing out common traits. The first such trait we identify is the ability of students to take (and complete) courses, which is implemented in an identical manner for regular and casual students. To represent this as an abstraction, we define the following protocol.
protocol CourseTaking {
var courses: Set<Course> { get set }
mutating func takeCourse(_: Course)
mutating func completeCourse(_: Course)
}
With the protocol in place, we can factor the implementation of the two methods out of the concrete conforming types, by extending the protocol as follows.
extension CourseTaking {
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
}
We can now remove these method implementations from the conforming types. Here is the revised RegularStudent
type.
struct RegularStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
func calculateFees() -> [Fee] {
var fees = [Fee]()
let enrollmentFee = Fee(
category: .enrollment,
amount: Config.enrollmentFee)
let courseFees = courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForEnrolledStudents)
}
fees.append(enrollmentFee)
fees.append(contentsOf: courseFees)
return fees
}
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
The same goes for the CasualStudent
type.
struct CasualStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
func calculateFees() -> [Fee] {
courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForNonEnrolledStudents)
}
}
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
All we have to do for these types to opt into the functionality required by the CourseTaking
protocol, and implemented using the above extension, is to extend the RegularStudent
and CasualStudent
types to conform to the CourseTaking
protocol. We add these protocols to the extensions we had created earlier, as shown below.
extension RegularStudent: CourseTaking, Invoicable {}
extension CasualStudent: CourseTaking, Invoicable {}
Note that we can add conformance to more than one protocol in the same extension or use separate extensions for each conformance. Protocol conformance can also be declared in the original definition of a type. Conformance added through an extension adds the relevant capabilities to the conforming types without affecting the capabilities added by any conformance declared earlier, whether in the original type definition or through an extension.
There is no rule as such on whether or not to combine protocol conformances or keep them separate. In general, if the type already conforms to the protocol either because the type definition contains the code or the code is made available through a protocol extension, as in this case, it may make code more readable to declare multiple conformances together to provide a fuller picture of the capabilities the type has. Where code needs to be added to support a new conformance, it may make sense to declare that conformance through a dedicated extension, which also contains the relevant code.
Constrained protocol extensions
A protocol extension can specify constraints that conforming types must satisfy for the extension to be available to them. This is done by adding a where
clause after the name of the protocol being extended. The most common use case is to require conforming types to also conform to another protocol but we can also make the constraint more specific such that the extension is available only to specific conforming types.
Going back to our running example, we continue to employ protocols to refactor the concrete types used to model students. Now we focus on the ability of students to calculate fees payable given the courses they have taken. To do this, we define the following protocol.
protocol FeePaying {
func calculateFees() -> [Fee]
}
Unlike with the CourseTaking
protocol, this time we have to deal with different behaviour depending on whether a student is regular or casual. Regular students are enrolled so they have to pay an enrollment fee in addition to course fees while casual students only pay course fees. Regular and casual students also pay a different fee per course credit.
We start by dealing with course fees for non-enrolled students through a constrained extension to the FeePaying
protocol, which is available only to conforming types that also conform to the CourseTaking
protocol.
extension FeePaying where Self: CourseTaking {
func calculateFees() -> [Fee] {
courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForNonEnrolledStudents)
}
}
}
Note that in the calculateFees()
method in the above protocol extension, we are able to use the courses
property from the CourseTaking
protocol even though it is not defined in the FeePaying
protocol or the extension. This is the magic of constrained protocol extensions. Since this extension is available only to conforming types that also conform to CourseTaking
, the compiler can be certain that conforming types that use this extension will have the courses
property, so it allows us to freely use this property.
This is all we need to give the CasualStudent
type the ability to calculate course fees. We just make CasualStudent
conform to the FeePaying
protocol.
extension CasualStudent: CourseTaking, FeePaying, Invoicable {}
Since CasualStudent
already conforms to CourseTaking
, the constrained extension we have defined to FeePaying
becomes available to the CasualStudent
type.
We no longer need the functionality dealing with fee calculations in the CasualStudent
type, which can be rewritten as follows.
struct CasualStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
Since regular students take courses but are also enrolled, we need an extension to FeePaying
which will be available to students who take courses and are enrolled. For this, we turn to protocol composition.
Protocol composition
Protocol composition combines multiple protocols into a single set of requirements. Although a protocol composition does not define a new protocol type, it has the same effect as defining a temporary protocol which combines the requirements of the protocols included in the composition. This protocol composition can then be used only once or, if the new abstraction represented by the protocol composition needs to be used in multiple places in the application, it can made a type in its own right by using the typealias
keyword.
Continuing our example, we turn our attention to regular students, who are enrolled so have to pay an enrollment fee in addition to fees for any courses that they may be taking at a given time. We start by defining the following protocol.
protocol Enrolled {}
Note that this protocol has no requirements. This is because, in the model as described, there is no specific behaviour linked to being enrolled. Being enrolled just modifies the calculation of fees. This is not to say that we may not have to model behaviour in the future which may be directly linked to being enrolled. When we do, we can use this protocol, and any extensions thereon, to model such behaviour. For now, we will use this protocol only as a constraint. We do this by defining an extension to the FeePaying
protocol which is available only to conforming types that also conform to both CourseTaking
and Enrolled
protocols. We use a composition of the Enrolled
and CourseTaking
protocols to define an extension to FeePaying
that is available only to concrete types that also conform to both these protocols.
extension FeePaying where Self: Enrolled & CourseTaking {
func calculateFees() -> [Fee] {
var fees = [Fee]()
let enrollmentFee = Fee(
category: .enrollment,
amount: Config.enrollmentFee)
let courseFees = courses.map() { course in
Fee(
category: .course(course.name),
amount: course.credits * Config.courseFeeForEnrolledStudents)
}
fees.append(enrollmentFee)
fees.append(contentsOf: courseFees)
return fees
}
}
This is what we will use to give regular students the ability to calculate fees applicable to them. We just make the RegularStudent
type conform to the Enrolled
and FeePaying
protocols, noting that it already conforms to the CourseTaking
protocol.
extension RegularStudent: Enrolled, CourseTaking, FeePaying, Invoicable {}
With this, we also don’t need any fee calculation logic in the RegularStudent
type, which we rewrite as follows.
struct RegularStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
Protocol inheritance
Protocols can inherit from other protocols. Unlike classes in Swift, which can inherit only from a single class, a protocol can inherit from multiple protocols. A protocol that inherits from one or more protocols gets all the requirements of the protocols it inherits from and can add its own requirements as well. This enables protocols to add to or specialize the requirements of other protocols.
In our running example, we have the generateInvoice()
method, which is implemented identically in the RegularStudent
and CasualStudent
types. Earlier, we created the Invoicable
protocol with the generateInvoice()
method and made RegularStudent
and CasualStudent
conform to it so we could generate invoices for students regardless of whether they are regular or casual. However, we did not extend Invoicable
to provide the implementation of generateInvoice()
to conforming classes. Now that we have moved the calculateFees()
method to the FeePaying
protocol, we can make the calculateFees()
method part of the Invoicable
protocol simply by making Invoicable
inherit from FeePaying
. To do this, we rewrite the Invoicable
protocol as below.
protocol Invoicable: FeePaying {
func generateInvoice() -> Invoice
}
Now we can implement generateInvoice()
in an extension to Invoicable
, using the calculateFees()
method as if it were defined in the Invoicable
protocol. Before we do that though, we need to take care of a small detail. The implementation of generateInvoice()
uses the fullName
property of the RegularStudent
and CasualStudent
types but this property is not part of the Invoicable
protocol so the generateInvoice()
method in an extension on this protocol will not compile.
Since the generateInvoice()
method requires the billed party to have a name, we create a new protocol with this exact requirement.
protocol Named {
var fullName: String { get set }
}
We can now create an extension on the Invoicable
protocol that requires conforming types to also conform to the Named
protocol, which is what our model requires.
extension Invoicable where Self: Named {
func generateInvoice() -> Invoice {
let fees = calculateFees()
let lineItems = fees.map() { fee in
let description =
switch fee.category {
case .enrollment:
"Enrollment fee"
case .course(let courseName):
"Fee for course '\(courseName)'"
}
return Invoice.LineItem(
description: description,
amount: fee.amount)
}
return Invoice(
billTo: fullName,
lineItems: lineItems)
}
}
Since the RegularStudent
and CasualStudent
types already conform to the Invoicable
protocol, we just need to add conformance to the Named
protocol to the RegularStudent
and CasualStudent
types for the above extension to be available to them.
extension RegularStudent: Named, Enrolled, CourseTaking, FeePaying, Invoicable {}
extension CasualStudent: Named, CourseTaking, FeePaying, Invoicable {}
The above extensions bring out the simplicity and clarity that the protocol-oriented approach brings to how we can see what attributes or behaviours a particular concrete type has in the context of the application. With the conformances ordered in a logical manner, it almost reads like a specification. For the RegularStudent
type, for instance, the extension makes it clear that a regular student has a name, is enrolled, takes courses, pays fees, and can be invoiced, without even looking at the implementations of any of these features. We can also see at a glance in what respects regular students are similar to, and differ from, casual students.
We have now factored out all functionality from the RegularStudent
and CasualStudent
concrete types. We rewrite these types below, simply providing storage for the id
, fullName
, and courses
properties, letting the protocols and extensions provide all the functionality required.
struct RegularStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
}
struct CasualStudent: Identifiable {
let id = UUID()
var fullName: String
var courses = Set<Course>()
}
It must be noted that we have worked through a simple example, where we could refactor all the functionality into protocol extensions, leaving the concrete types responsible only for providing storage for properties. Real-life projects are more complex, however, and we should fully expect functionality to be distributed between concrete types and extensions to protocols to which the concrete types conform. The advantage of using protocols and protocol extensions to introduce abstractions and factor out common functionality is that abstractions can be added retroactively as and when required. We can change and even replace existing abstractions as domain requirements evolve or as the scope of the model changes.
Protocols as traits
What we have done over the last few sections is to take functionality implemented in concrete classes and convert it into traits, which concrete classes can simply opt into, without having to provide implementations. This is made possible through protocol extensions, which let us provide default implementations to conforming types, even imposing conditions where appropriate on which implementation should be available to which conforming types. The conforming types still have to provide storage for any stored properties that these traits may use, but it makes the process of adding capabilities to new concrete types much easier and simpler.
In the example that we have worked through, we started with concrete types and retroactively modeled the traits. As noted earlier, retroactive modeling saves us from having to decide on key abstractions early in the design process. Once we have fleshed out the basic design and we can begin to think about some of the design elements in terms of abstractions, we can also start directly designing traits and adding them to concrete types. Whether we tease out a trait retroactively from concrete types or design it directly and inject it into concrete types depends on how well we understand a particular aspect of the model and how far along we are in the design process. In any moderately complex domain, we would likely end up using a combination of both approaches.
To demonstrate designing a trait directly, we go back to our running example and look at the last requirement we had specified, i.e., regular students should have exclusive access to campus facilities. In this case, we assume these facilities include a gym and a library. First, we use a protocol to define the required trait.
protocol FaciliyUsing {
func useGym()
func useLibrary()
}
Next, we use a protocol extension to implement the trait.
extension FaciliyUsing {
func useGym() {
print("Using the gym")
}
func useLibrary() {
print("Using the library")
}
}
All we need now is to apply this trait to the RegularStudent
type. Note that this time we use a new extension to add the conformance just to demonstrate this approach.
extension RegularStudent: FaciliyUsing {}
We can see how a regular student can enjoy access to the gym and the library.
regularStudent.useGym()
// Using the gym
regularStudent.useLibrary()
// Using the library
The methods useGym()
and useLibrary()
are not available to instances of CasualStudent
and we will get a compiler error if we try to use these methods with instances of CasualStudent
.
Having traits makes it easier to apply existing capabilities to new concrete types that we may need as we develop the application. Let’s say we are asked to model summer students who join for a limited period for a fixed fee. They can take courses and use facilities but, given that the fixed fee is charged up front, there is no need to calculate fees or generate invoices. We can use the traits we have already defined to create a new type as shown below. Note that this time we declare conformance of the required protocols in the original type definition since we know in advance what conformances we need to give our new type the required functionality.
struct SummerStudent: CourseTaking, FaciliyUsing {
var courses = Set<Course>()
}
Overriding default implementations
While protocol extensions allow us to provide default implementations to conforming types, conforming types remain free to override any of these with their own implementations. To see this in action, we continue our running example, where we last created a concrete type to model summer students. Let’s say we want to impose a condition that summer students cannot take more than two courses at a given time.
To do this, we first add the following property to the Config
type we had created earlier.
static let maxCoursesForSummerStudents = 2
Now we can extend the SummerStudent
type as shown below.
extension SummerStudent {
mutating func takeCourse(_ course: Course) {
guard courses.count < Config.maxCoursesForSummerStudents else {
print("Not allowed to take any more courses")
return
}
courses.insert(course)
}
}
We have overridden the default implementation of takeCourse(_:)
from the CourseTaking
protocol to impose this check. We test this out by creating an instance of SummerStudent
and seeing if it can take more than two courses.
First, we make the summer student take two courses and check the course count.
var summerStudent = SummerStudent()
summerStudent.takeCourse(swift101)
summerStudent.takeCourse(dataStruct102)
print(summerStudent.courses.count)
// 2
Then we create a new Course
instance and try to make the summer student take this course. This results in the expected message being printed and the course count remains at 2, as show below.
let math101 = Course(name: "Math 101", credits: 3)
summerStudent.takeCourse(math101)
// Not allowed to take any more courses
print(summerStudent.courses.count)
// 2
Conclusion
Protocol-oriented programming in Swift opens up a whole new way of designing applications. Rather than having to lock ourselves into inflexible inheritance hierarchies by being forced to settle on key abstractions early in the design process, Swift protocols and protocol extensions enable retroactive modeling which means we can start with concrete code and work our way to abstractions when we need to assign a role or a persona to entities or concepts in the domain. Even when an abstraction is introduced, we remain free to evolve it or even replace it as and when required by the needs of the application. The protocol-oriented paradigm makes it easy to create traits, which can be applied to concrete types to let them opt into new functionality at any time without affecting the rest of our design.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post or start a conversation on X.
Leave a Reply