At WWDC 2015, Apple announced that Swift is the first protocol-oriented programming language (
video session "Protocol-Oriented Programming in Swift" ).
At this session and a number of others (
Swift in Practice ,
Protocol and Value Oriented Programming in UIKit Apps ) Apple demonstrates good examples of using protocols, but does not give a formal definition of what Protocol-Oriented Programming is.
There are many articles on Protocol-Oriented Programming (POP) on the Internet that demonstrate examples of using protocols, but I haven’t found a clear definition of POP in them either.
')
I tried to analyze examples of using protocols and formulate principles that should be followed, so that the code could be called protocol-oriented.
By looking at code examples demonstrating POP, you can determine that the key language features in POP are
protocol ,
extensions, and
constraints .
Let's see what opportunities they give us.
Protocol
Using the protocol can be divided into several scenarios:
Protocol as a type
Similar to the concept of the
interface of the PLO and the contract of
contract programming . Used to describe the functionality of the object. It can be used as a property type, as a function result type, as an element of a heterogeneous collection. Due to language restrictions, protocols having associated types or Self-requirements cannot be used as types.
Protocol as a type template
Similar to the
concept of a
concept of
generalized programming .
It also serves to describe the functionality of an object, but, unlike “protocol as a type,” it is used as a type requirement in generic functions. May contain associated types.
associated types - auxiliary types that have some relevance to the concept-modeling type (definition from wikipedia ).
There is no clear line in which case to use the protocol as a type, and in which - as a restriction on the type, moreover - sometimes it is required to use the protocol in both scenarios. You can try to isolate use cases:
- classes that provide functionality for higher layers of the application and are passed to consumer classes as dependencies — services, repositories, api-clients, user settings, and so on.
In this case, it is more convenient to use the protocol as a type — it can be registered in the IOC container, and without using it, it is not required in each function where this service is used to add the type-parameter.
- A protocol describing mathematical operations, such as comparison, addition, concatenation, and similar things. In this case, it is convenient to use Self-requirement (when the pseudotype Self is used in a function or property of the protocol) to avoid dangerous reduction and use of different types, when the operation allows only one type of parameters (both Int and String in Swift correspond to the Equatable protocol, but if you try check them for equality between themselves, the compiler will generate an error, since the comparison operator requires that the parameters be of the same type). Therefore, in this case, the protocol is used as a type template.
- sometimes it is required to save the protocol having associated types in a private property, but in this case we cannot use the protocol as a type. There are different ways to solve this problem, for example, creating a similar protocol in which the use of associated types will be replaced with specific types; use of type erasure technique - in this case, the associated types will move to generic parameters of type Any [YourProtocolName]. Another option is not to save the instance itself, but its functions. Or grab the instance in the closure, which is stored in the property.
Protocol as trait
Trait (type) - an entity that provides a set of implemented functionality. Serves as a set of building blocks for classes / structures / enums.
Description of the concept of traits can be found
here .
This concept is designed to replace inheritance. In OOP, one of the roles of classes is the unit of the code being reused. The reuse itself is through inheritance.
At the same time, the class is used to create instances, so its functionality must be complete. These two class roles often conflict. Plus, each class has a certain place in the class hierarchy, and the unit of code reuse can be used in an arbitrary place. As a solution, it is proposed to use more lightweight entities, traits, in the role of code reuse units, while classes will be assigned the role of a connecting element to enable logic inherited from traits.
In swift, this concept is implemented through protocols and protocol extensions. In order to “connect” the necessary functions defined for the protocol, you need to add a correspondence to this protocol to the created type - there is no need to create a base class for inheriting functionality.
What trait properties and protocol analogy has:
- trait provides a set of methods that implement the behavior. - Methods added using protocol extensions;
- trait requires a set of methods that serve as parameters for providing behavior. - Methods contained in the protocol itself (Protocol requirements);
- traits do not set variables to store state. The methods provided by trait do not have direct access to the class fields. - Extension methods cannot add a stored property type. Protocol cannot add the requirement of what the computed or stored property should be, so extension methods do not have direct access to the data — it is done through property accessors;
- classes and traits can be made up of other traits. Method conflicts must be explicitly resolved. - Classes can be added to correspond to protocols, and protocols support inheritance to other protocols. Conflicts can be resolved, for example, by using a cast to a specific type:
protocol Protocol1 { } protocol Protocol2 { } protocol ComposedProtocol: Protocol1, Protocol2 { } extension Protocol1 { func doWork() { print("Protocol1 method") } } extension Protocol2 { func doWork() { print("Protocol1 method") } } extension ComposedProtocol { func combinedWork() { (self as Protocol1).doWork() (self as Protocol2).doWork() print("ComposedProtocol method") } }
- adding a trait does not affect the semantics of the class - there is no difference between whether the methods from the traits or methods defined directly in the class are used. - True for protocols - looking at the code, we cannot determine where the method is defined - in the protocol extension or the type corresponding to the protocol;
- trait composition does not affect the trait semantics — a composite trait is equivalent to a “flat” trait containing the same methods. - The use of the Foo protocol with the foo () method, which is inherited from the Bar protocols with the bar () method and the Baz method with the baz () method does not differ from the use of the protocol, with these 3 methods: foo (), bar (), baz ().
As we can see, the protocols fully comply with the concept of traits, described long before the advent of Swift.
Protocol as a marker
Used as an “attribute” of a type, in this case the protocol does not contain any methods. As an example, NSFetchRequestResult from CoreData. They are labeled NSNumber, NSDictionary, NSManagedObject, NSManagedObjectID. The protocol in this case does not describe the functionality of classes, but the fact that CoreData supports these classes as a type of query result. If you specify a type unlabeled with the NSFetchRequestResult protocol as a result, then at the assembly stage you will get an error.
Check for the presence of the protocol-marker can be used for branching logic:
if object is HighPrioritized { ... }
Extensions
Extension is a language tool that allows you to add functionality to an existing type or protocol.
With the help of extensions we can do:
- add a method to the protocol - this method will be available (within the scope) for use both within types that correspond to this protocol and for their customers;
- add a class / structure / enum to the protocol. At the same time, we do not need access to the code of these types, they may be contained in a third-party library. This feature is called Retroactive modeling.
We cannot make the protocol comply with another protocol. If this were possible, with the P1 protocol, which conforms to the P2 protocol, all types corresponding to the P1 protocol would automatically correspond to the P2 protocol. As a workaround to this problem, we can use the following technique: write an extension for the P1 protocol, in which we write implementations of the P2 protocol methods, after which we can add a P2 correspondence to the types corresponding to P1, without implementations of the methods. This idea is well demonstrated by an example from the presentation of POP - Retroactive adoptation:
protocol Ordered { func precedes(other: Self) -> Bool } extension Comparable { // , Comparable, : // extension Ordered where Self: Comparable // // extension Comparable where Self: Ordered func precedes(other: Self) -> Bool { return self < other } } extension Int : Ordered {} extension String : Ordered {}
- write the default implementation of the protocol method. If the type contains an implementation of this method, then it will be used instead of the default one.
Constraints
Restrictions on type. The following are supported: protocol compliant, inherited from class, has type. Constraints are used to determine the set of methods that a generic type has. If you pass an unsatisfying type as an argument, the compiler will generate an error.
Where are used:
- restrictions on the types of parameters in the definition of a generalized function. Example: the produce function takes a type argument that conforms to the Factory protocol, whose associative Product type should be Cola:
func produce<F: Factory>(factory: F) where F.Product == Cola
Another example: the argument must simultaneously comply with 2 protocols: Animal and Flying:
// : func fly<T>(f: T) where T: Flying, T: Animal { ... } func fly<T: Flying & Animal>(f: T) { ... } func fly<T: Animal>(f: T) where T: Flying { ... } func fly<T>(f: T) where T: Flying & Animal { ... }
- restriction on associated type in the protocol definition. An example - the associative type Identifier must comply with the Codable protocol:
protocol Order { associatedtype Identifier: Codable }
We can make associated types associated types:
protocol GenericProtocol { associatedtype Value: RawRepresentable where Value.RawValue == Int func getValue() -> Value } // . : protocol GenericProtocol where Value.RawValue == Int { associatedtype Value: RawRepresentable func getValue() -> Value } protocol GenericProtocol where Value: RawRepresentable, Value.RawValue == Int { associatedtype Value func getValue() -> Value }
- restriction on the availability of extension methods. Let's rewrite examples of functions from Animal and Factory to extension methods:
extension Animal where Self: Flying { func fly() { ... } } extension Factory where Product == Cola { func produce() { ... } }
- definition of conditional compliance protocol ( Conditional Conformance ). Example: if the type of the array elements corresponds to the ObjectWithMass protocol, then the array itself will correspond to this protocol, and as the mass it will return the sum of the mass of elements:
protocol ObjectWithMass { var mass: Double { get } } extension Array: ObjectWithMass where Element: ObjectWithMass { var mass: Double { return map { $0.mass }.reduce(0, +) } }
Since the associatedtypes collections can be specified both on the protocol itself and for the methods to which the protocol is transferred, and for the protocol extensions, the question arises where to add the constraints. A few recommendations:
- If the protocol is application specific and will have one implementation, it is worth considering the possibility of using specific types instead of associated ones.
- If the protocol is application-specific and will have several implementations (taking into account fake tests), it is more convenient to put them in the protocol itself so as not to duplicate to the places where this protocol is used.
- If there are plans to reuse the protocol, the protocol should contain only those frameworks without which the existence of the protocol does not make sense and on which the main logic is built. All other constraints should be considered as a description of special cases and placed on methods and extensions.
Principles
The best material on POP is the
“Protocol-Oriented Programming in Swift” session conducted by Dave Abrahams. I strongly recommend it for viewing. Most of the principles are shaped by examples from it.
- “Don't start with a class. Start with a protocol. ” This is the statement of Dave Abrahams from the above session. It can be interpreted in two ways:
- start not with the implementation, but with the description of the contract (description of the functionality that the object will be required to provide to consumers)
- Describe reusable logic in protocols, not classes. Use the protocol as a unit of code reuse, and the class as a place for unique logic. Otherwise, you can describe this principle - encapsulate what varies .
The Template Method method can be a good analogue. His idea is to separate the general algorithm from the implementation details. The base class contains the general algorithm, and the children override certain steps of the algorithm. In POP, the general algorithm will be contained in the protocol extension, the protocol will determine the steps of the algorithm and the types used, and the implementation of the steps in the class.
- Composition through extensions. Many have heard the phrase “prefer composition over inheritance” . In OOP, when a different set of functionality is required from an object (polymorphic behavior), this functionality can either be divided into parts and a hierarchy of classes is organized, where each class inherits the functionality from its ancestor and adds its own, or divided into unrelated classes by hierarchy, instances of which are used in a binder. classroom. Using the ability to add conformance to the protocol through an extension, we can use composition without resorting to creating auxiliary classes. Often used in this way when viewController is added to match various delegates. The advantage over adding conformance to protocols in the class itself is better organized code:
extension MyTableViewController: UITableViewDelegate {
- Instead of inheritance, use protocols. Dave Abrahams compared the protocols with superclasses because they allow to achieve the similarity of multiple inheritance.
If your class contains a lot of logic, you should try to break it up into separate sets of functionality that you can put into protocols.
Of course, if third-party frameworks like Cocoa are used, inheritance cannot be avoided.
- Use Retroactive modeling.
An interesting example from the same session “Protocol-Oriented Programming in Swift”. Instead of writing a class that implements the Renderer protocol for rendering using CoreGraphics, the CGContext class via extension adds conformance to this protocol. Before adding a new class that implements the protocol, it is worth considering whether there is a type (class / structure / enum) that can be adapted to match the protocol?
- Include protocols that you can override ( Requirements create customization points ) in the logs.
If you need to override the generic method defined in the protocol extension for a particular class, then transfer the signature of this method to protocol requirements. Other classes do not have to edit, because will continue to use the method from the extension. The difference will be in the wording - now it is the “default implementation method” instead of the “extension method” .
Differences POP from OOP
Abstraction
In OOP, the role of an abstract data type is played by a class. In the POP protocol.
Advantages of the protocol as an abstraction, according to Apple (slide:
“A Better Abstraction Mechanism” ):
- Supports value types (and classes)
- Supports static type relationships (and dynamic dispatch)
- Non-monolithic
- Supports retroactive modeling
- Doesn’t impose instance data on models
- Doesn't impose initialization weights on models
- Makes clear what to implement
Transfer- Support for value types (and classes)
- Support static type relationships (and dynamic dispatch)
- Non monolithic
- Retroactive modeling support
- Does not impose object data (fields of the base class)
- Does not burden initialization (base class)
- Makes it clear what to implement
Encapsulation
- the property of the system, allowing to combine data and methods working with them in the class.
The protocol cannot contain the data itself; it can contain only the requirements for the properties that these data would provide. As in OOP, the necessary data should be included in the class / structure, but functions can be defined both in the class and in extensions.
Polymorphism
POP / swift supports 2 types of polymorphism:
- polymorphism of subtypes. It is used in OOP:
func process(service: ServiceType) { ... }
- parametric polymorphism. Used in generic programming.
func process<Service: ServiceType>(service: Service) { ... }
The set of functions of the received type and its associated types are determined by the restrictions. We can not impose restrictions, but in this case the parameter will be similar to the Any type:
func foo<T>(value: T) { ... }
In the case of polymorphism of subtypes, we do not know the specific type that is passed to the function - finding the implementation of methods of this type will be carried out at runtime (
Dynamic dispatch ). When using parametric polymorphism - the type of the parameter is known at compile time, respectively, and its methods (
Static dispatch ). Due to the fact that at the assembly stage the types used are known, the compiler is able to better optimize the code — first of all, through the use of substitution (inline) functions.
Inheritance
Inheritance in OOP is used to borrow functionality from the parent class.
In POP, obtaining the necessary functionality occurs by adding correspondences to the protocols that provide functions via extensions. At the same time, we are not limited to classes; we have the opportunity to expand structures and enums with protocols.
Protocols can be inherited from other protocols - this means adding requirements from the parent protocols to your own requirements.
Let's see how you can use POP in practice.
Example 1
The first example is an upgraded version of SegueHandler, presented at
WWDC 2015 - Session 411 Swift in Practice .
Imagine that we have a RootViewController and we need to do the processing of transitions to DetailViewController and AboutViewController. A typical implementation of
prepare (for: sender :) :
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segue.identifier { case "DetailViewController": guard let vc = segue.destination as? DetailViewController else { fatalError("Invalid destination view controller type.") }
We know that we can have only 2 transitions - with id DetailViewController and AboutViewController with the same controller classes, however, we have to do a check for the unknown seque.identifier and type conversion segue.destination.
Let's try to improve the code of this method. Let's start with a description of possible transitions - enum is perfect for this:
enum SegueDestination { case detail(DetailViewController) case about(AboutViewController) }
(Note: SegueDestination is declared inside the RootViewController)
Our goal is to write a universal helper method for conversion processing. To do this, we define the SegueHandlerType protocol with an associated type describing the transition. The associated type requirement is that it must provide a failable initializer that returns nil in case of a non-valid combination of segue id and controller type:
protocol SegueHandlerType { associatedtype SegueDestination: SegueDestinationType } protocol SegueDestinationType { init?(segueId: String, controller: UIViewController) }
The protocol is defined, now we add the segueDestination (forSegue :) method for it that returns a transition instance:
extension SegueHandlerType { func segueDestination(forSegue segue: UIStoryboardSegue) -> SegueDestination { guard let id = segue.identifier else { fatalError("segue id should not be nil") } guard let destination = SegueDestination(segueId: id, controller: segue.destination) else { fatalError("Wrong segue Id or destination controller type") } return destination } }
Let's make RootViewController implement SegueHandlerType (we will put it into a separate file so that this trivial code will rarely catch the eye):
// file RootViewController+SegueHandler.swift extension RootViewController.SegueDestination: SegueDestinationType { init?(segueId: String, controller: UIViewController) { switch (segueId, controller) { case ("DetailViewController", let vc as DetailViewController): self = .detail(vc) case ("AboutViewController", let vc as AboutViewController): self = .about(vc) default: return nil } } } extension RootViewController: SegueHandlerType { }
I want to note that the associated type in SegueHandlerType and enum in RootViewController have the same name, so the implementation of SegueHandlerType for RootViewController is empty. In the case of different names, and if our enum were not defined inside the RootViewController, we would need to specify the type associated with the protocol using typealias:
extension RootViewController: SegueHandlerType { typealias SegueDestination = RootControllerSegueDestination }
The final part of the example - now we can refactor prepare (for: sender :):
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segueDestination(forSegue: segue) { case .detail(let vc):
The code has become much cleaner, right?
Of course, as a result, the code became more - but we managed to separate the main logic (the one that is hidden behind the comments "// configure vc") and the auxiliary code. Pros - the code has become easier to read, and the auxiliary SegueHandlerType can be reused.
Example 2
Consider a typical task to display a list of items in UITableView.
As the source data we have the
Cat model and
TestCatRepository , which corresponds to the
CatRepository protocol:
struct Cat { var name: String var photo: UIImage? } protocol CatRepository { func getCats() -> [Cat] }
Table and cell controller classes have been added to the project:
CatListTableViewController ,
CatTableViewCell .
Let's try to describe a generalized protocol list. Imagine that we have plans to add other tables to the project, which, in particular, may contain several sections. Protocol requirements:
- we should be able to set the number of elements in the section;
- it must be possible to specify types for the section index and for the element index — they can be any — a number, enum, tuple, we do not impose any requirements on them;
- cell type - we also do not impose any requirements on it, maybe just a TableViewCell, it can be a more complex type to get a cell of a certain type, if the table uses different types of cells (different Cell Identifier)
- the ability to process a cell update request - the easiest way is to assign a handler as a function with 2 parameters - a cell and its index.
Taking into account the requirements made, we write our protocol:
protocol ListViewType: class { associatedtype CellView associatedtype SectionIndex associatedtype ItemIndex func refresh(section: SectionIndex, count: Int) var updateItemCallback: (CellView, ItemIndex) -> () { get set } }
Let's describe the cell requirements for displaying cat information:
protocol CatCellType { func setName(_: String) func setImage(_: UIImage?) }
Add compliance with this protocol to the CatTableViewCell class.
Our main protocol, ListViewType, must be added to CatListTableViewController. We use only one type of cell - CatTableViewCell, so we use it as the associatedtype CellView. There is only one section in the table and the number of elements is not known in advance - we use Void and Int as SectionIndex and ItemIndex, respectively.
Full implementation of CatListTableViewController:
class CatListTableViewController: UITableViewController, ListViewType { var itemsCount = 0 var updateItemCallback: (CatTableViewCell, Int) -> () = { _, _ in } func refresh(section: Void, count: Int) { itemsCount = count tableView.reloadData() } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return itemsCount } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CatCell", for: indexPath) as! CatTableViewCell updateItemCallback(cell, indexPath.row) return cell } }
Now our goal is to connect CatRepository and ListViewType. However, I do not want to associate the algorithm with a specific Cat model. To do this, we select generalized protocols, where the type of the model is in the associatedtype:
protocol RepositoryType { associatedtype Model func getItems() -> [Model] } protocol ConfigurableViewType { associatedtype Model func configure(using model: Model) }
Add compliance with new protocols:
extension CatRepository { func getItems() -> [Cat] { return getCats() } } extension TestCatRepository: RepositoryType { } extension CatCellType where Self: ConfigurableViewType { func configure(using model: Cat) { setName(model.name) setImage(model.photo) } } extension CatTableViewCell: ConfigurableViewType { }
Everything is ready to implement the method of displaying objects provided by the RepositoryType in the ListViewType list. The algorithm will not support multiple sections, but uses Int as an index. Add restrictions on the extension:
extension ListViewType where SectionIndex == (), ItemIndex == Int { ... }
Our CatListTableViewController complies with these limitations.
But these are not all limitations - ListViewType.CellView should be ConfigurableViewType, and its Model type should be RepositoryType.Model:
func setup<Repository: RepositoryType>(repository: Repository) where CellView: ConfigurableViewType, CellView.Model == Repository.Model { ... }
And these restrictions correspond to our class.
Full extension code:
extension ListViewType where SectionIndex == (), ItemIndex == Int { func setup<Repository: RepositoryType>(repository: Repository) where CellView: ConfigurableViewType, CellView.Model == Repository.Model { let items = repository.getItems() refresh(section: (), count: items.count) updateItemCallback = { cell, index in let item = items[index] cell.configure(using: item) } } }
The main logic is ready, we use this function in AppDelegate:
let catListTableView = window!.rootViewController as! CatListTableViewController let repository = TestCatRepository() catListTableView.setup(repository: repository)
.
, .
extensions, , , . , : ? ListViewType. CatListTableViewController , . CatListTableViewController , :
catListTableView.setup(repository: repository)
CatListTableViewController — Controller MVC. — MVC, .
Conclusion
Protocol-Oriented Programming Generic Programming Traits.
POP , , , , .
Sources:
- WWDC 2015 «Protocol-Oriented Programming in Swift»
- WWDC 2015 «Swift in Practice»
- WWDC 2016 «Protocol and Value Oriented Programming in UIKit Apps»
- type erasure
- Traits: Composable Units of Behaviour