Info
All of the following code is just pseudocode to explain the principles.
Similarities
Protocol oriented programming is one of the biggest paradigms out there. I'd say the biggest counterpart of protocol oriented programming is object orientation. They are kind of similar in some ways and very different in others. I'd say the most important similarities are the reusability of code and polymorphism (treating different objects of different types as instances of a superclass or protocol).
class Sound {
func playSound() {
print("playing sound")
}
}
class ClockTick: Sound {
var soundfile = "clocktick.wav"
override func playSound() {
//here would you add the logic to play the soundfile
}
}
class Engine: Sound {
var soundfile = "clocktick.wav"
}
func useSound(sound: Sound) {
sound.playSound()
}
let clockTick = ClockTick()
let engine = Engine()
useSound(clockTick)
useSound(engine)
As you can see, ClockTick and Engine are different objects. But since they both inherit from Sound, they can both be passed to a function expecting an object "Sound". If we don't override the function playSound() the default implementation gets used.
Using protocols it looks a little, but not much different:
protocol Sound {
var soundfile: String { get }
func playSound() -> Void
}
class ClockTick: Sound {
var soundfile = "clocktick.wav"
func playSound() {
//here would you add the logic to play the soundfile
}
}
class Engine: Sound {
var soundfile = "clocktick.wav"
func playSound() {
//here would you add the logic to play the soundfile
}
}
func useSound(sound: Sound) {
sound.playSound()
}
let clockTick = ClockTick()
let engine = Engine()
useSound(clockTick)
useSound(engine)
In the example it's mostly the same. But its not all. The protocol defines both the variable soundfile and the function playSound() must exist. The implementation of playSound() is exactly the same in both classes. Thats unnecessary code replication. Lets fix it:
protocol Sound {
var soundfile: String { get }
func playSound() -> Void
}
extension Sound {
func playSound() {
//here would you add the logic to play the soundfile
}
}
class ClockTick: Sound {
var soundfile = "clocktick.wav"
}
class Engine: Sound {
var soundfile = "clocktick.wav"
}
func useSound(sound: Sound) {
sound.playSound()
}
let clockTick = ClockTick()
let engine = Engine()
useSound(clockTick)
useSound(engine)
With protocol extensions you can provide default functions. Since the protocol defines any Sound conforming object must have the variable soundfile, we can just use it in the extension. So far so similar.
Differences
The biggest flaw of OOP is, in Swift, like in most other languages supporting OOP, you can only inherit from one superclass. A different problem of OOP is how tight inheritances are coupled.
Such they can be hard(er) to maintain, may not be that reuseable, may be not very testable, leading to complexer unit-tests and are hard to extend or replace. That hurts skalability and how easy you can understand the code.
Those problems are where OOP can become more of a burden instead of helping.
Lets say, you want an object to have not one, but multiple uses in an object. A common example would be Encodable, Identifiable, Hashable. If those were classes you would need multiple parent-child objects, to make one object conforming to them all. And that for every possible combination there is. Using protocols, you can just define the protocols and make any object you want to as many of them as you like.
protocol Sound {
var soundfile: String { get }
func playSound() -> Void
}
extension Sound {
func playSound() {
//here would you add the logic to play the soundfile
}
}
protocol Record {
var title: String { get }
var performer: Performer { get }
var features: [Performer] { get }
}
struct Performer {
var name: String
var birthdate: String
}
protocol Identifiable {
var id: UUID { get }
}
struct LastChristmas: Sound, Record, Identifiable {
let id = UUID()
let soundfile = "lastChristmas.wav"
let title = "Last Christmas"
let performer = Performer(name: "Mariah Carey", birthdate: "1969-03-27")
let features = [Performer]()
}
Protocols are like smaller, easily combinable building-blocks. You have none of the hassle of class inheritance, you can just combine them however you like.
Another handy example could be: Imagine you have multiple views, that share some common functions, for example opening a new Tab. Then you could just create a protocol with default functions, like this:
protocol TabOpener {
var tabViewModel: TabViewModel { get } // TabViewModel is an example for a view model that could contain the tabs
func openNewTab(url: URL) -> Void
}
extension TabOpener {
func openNewTab(url: URL) {
//tab opening function
}
}
struct DefaultTabButton: View, TabOpener {
@EnvironmentObject var tabViewModel: TabViewModel
var body: some View {
Button("Open google.com") {
guard let url = URL(string: "https://google.com") else { return }
openNewTab(url)
}
}
}
struct URLInputView: View, TabOpener {
@EnvironmentObject var tabViewModel: TabViewModel
@State var textInput = ""
var body: some View {
TextField("URL", text: $textInput)
Button("Open") {
guard let url = URL(string: textInput) else { return }
openNewTab(url)
}
}
}
Using protocols like this has the benefit of both having to write less code, and maybe even more important - having consistent functionality with ease. The way you open your tabs changes? No problem, just update the one default function in the TabOpener extension. Having the default implementation at just one place can heavily increase readability and maintainability.
Testing
Another use-case of protocols is abstraction. Lets say your code fetches stuff or relies on IO operations. Thats often something you don't want in your unit tests. Unit tests are about - as the name tells - a unit. IO operations and fetches can both slow down your test execution and unexpectedly make a test fail, even though the logic is completely correct.
A common way to tackle this is using dependency injection - which is a common principle for testing. Let me give you an example of the concept:
protocol NetworkOperator {
func fetchPage(urlString: String) async -> String?
}
struct TitleFetcher {
var networkOperator: NetworkOperator
func fetchTitle(for urlString: String) async -> String? {
guard let pageContent = await networkOperator.fetchPage(urlString: urlString) else { return nil }
//parsing logic
}
}
struct Network: NetworkOperator {
func fetchPage(urlString: String) async -> String? {
//network fetch logic
}
}
struct MockNetwork: NetworkOperator {
func fetchPage(urlString: String) async -> String? {
return """
mock page content string
"""
}
}
//actual code usage:
let titleFetcher = TitleFetcher(networkOperator: Network())
//in unit tests
let titleFetcher = TitleFetcher(networkOperator: MockNetwork())
Now you don't rely on network anymore and can just test the parsing functionality :)
Conclusion
Protocols are a very versatile way to write less and more consistent code. Their biggest advantage over OOP principles is being combinable however you like without the need for complex inheritance-hierarchies.
That doesn't mean OOP is bad though. UIKit for example relies heavily on OOP. Delegates are classes with a lot of default functionality you can override if needed. In case you need to use classes and only one parent class, OOP might still be a solid option. And you could always combine OOP with Protocols. Another (potentially big) advantage of OOP is that you clearly see when you override a function, because it requires the override keyword. With protocols you could accidentally overwrite a default implementation.
You would often combine both. Don't be afraid of using whatever fits your usecase best.