Final article on protocol-oriented programming.
In this part, we will look at how variables of a generic type are stored and copied, and how the dispatch method works with them.
Unshared version
protocol Drawable { func draw() } func drawACopy(local: Drawable) { local.draw() } let line = Line() drawACopy(line) let point = Point() drawACopy(point)
Very simple code. drawACopy
takes a parameter of type Drawable and calls its draw method - thatβs all.
Generalized version
Let's look at the generalized version of the code above:
func drawACopy<T: Drawable>(local: T) { local.draw() } ...
Nothing seems to have changed. We can still just call the drawACopy
function, as its drawACopy
version, and nothing more, but the most interesting as usual under the hood.
Generalized code has two important features:
- static polymorphism (also known as parametric)
- a specific and unique type in the context of the call (a generic type T is defined at compile time)
Consider this as an example:
func foo<T: Drawable>(local: T) { bar(local) } func bar<T: Drawable>(local: T) { ... } let point = Point(...) foo(point)
The most interesting part begins when we call the foo
function. The compiler knows exactly the type of the point
variable - it's just Point. Moreover, the T: Drawable type in the foo
function can be freely inferred by the compiler from the moment we pass a variable of the known Point type to this function: T = Point. All types are known at compile time and the compiler can perform all its wonderful optimizations - the most important thing is to inline the foo
call.
This: ```swift let point = Point(...) foo<T = Point>(point) Becomes this: ```swift bar<T = Point>(point)
The compiler simply embeds the foo
call with its implementation and displays the generic type of T: Drawable bar, too. In other words, the compiler first embeds a call to the foo method with type T = Point, then it embeds the result of the previous embedding - the bar method with type T = Point.
Implementation of generic methods
func drawACopy<T: Drawable>(local: T) { local.draw() } drawACopy(Point(...))
Internally, drawACopy
Swift uses a protocol-method table (which contains all implementations of the T method) and a life cycle table (which contains all the life cycle methods for the T instance). In pseudo code, it looks like this:
func drawACopy<T: Drawable>(local: T, pwt: T.PWT, vwt: T.VWT) {...} drawACopy(Point(...), Point.pwt, Point.vwt)
VWT and PWT are associated types (associatedtype) in T - as type aliases (typealias), only better. Point.pwt and Point.vwt are static properties.
Since in our example T is Point, T is well defined, therefore, the creation of a container is not required. In the previous drawACopy
version of drawACopy
(local: Drawable), the creation of an existential container was carried out as necessary - we examined this in the second part of the article.
A lifecycle table is required in functions due to the creation of an argument. As we know, arguments in Swift are passed through values, not through links, therefore, they must be copied, and the copy method for this argument belongs to the lifecycle table like this argument. There are also other lifecycle methods there: allocate, destruct and deallocated.
A life cycle table is required in generic functions due to the use of methods for generic code parameters.
Generalized or non-generalized?
Is it true that using generic types makes code execution faster than using protocol types only? Is the generic function func foo<T: Drawable>(arg: T)
faster than its protocol-like counterpart fun foo(arg: Drawable)
?
We noticed that generalized code gives a more static form of polymorphism. It also includes compiler optimizations called "Generic Code Specialization." Let's get a look:
Again we have the same code:
func drawACopy<T: Drawable>(local: T) { local.draw() } drawACopy(Point(...)) drawACopt(Line(...))
Specialization of a generic function creates a copy with specialized generic types of this function. For example, if we call drawACopy
with a variable of type Point, then the compiler will create a specialized version of this function - drawACopyOfPoint
(local: Point), and we get:
func drawACopyOfPoint(local: Point) { local.draw() } func drawACopyOfLine(local: Line) { local.draw() } drawACopy(Point(...)) drawACopt(Line(...))
What can be reduced by crude compiler optimization before this:
Point(...).draw() Line(...).draw()
All these tricks are available because generic functions can only be called if all generic types are defined - in the drawACopy
method drawACopy
generic type (T) is well defined.
Generic Stored Properties
Consider a simple struct pair:
struct Pair { let fst: Drawable let snd: Drawable } let pair = Pair(fst: Line(...), snd: Line(...))
When we use this in this way, we get 2 allocations on the heap (the exact memory conditions in this scenario were described in the second part), but we can avoid this with the help of a generalized code.
The generic version of Pair looks like this:
struct Pair<T: Drawable> { let fst: T let snd: T }
From the moment the type T is defined in the generalized version, the property types fst
and snd
same and are also defined. Since the type is defined, the compiler can allocate a specialized amount of memory for these two properties - fst
and snd
.
In more detail about the specialized amount of memory:
when we are working with a fst
version of Pair
, the property types fst
and snd
are Drawable. Any type can correspond to Drawable, even if it takes 10 KB of memory. That is, Swift will not be able to draw a conclusion about the size of this type and will use a universal memory location, for example, an existential container. Any type can be stored in this container. In the case of generic code, the type is well recognized, the actual size of the properties is also recognizable, and Swift can create a specialized memory location. For example (generalized version):
let pair = Pair(Point(...), Point(...))
Type T is now Point. Point takes N bytes of memory and in Pair we get two of them. Swift will allocate 2 * N amount of memory and put pair
there.
So, with the generic version of Pair, we get rid of unnecessary allocations on the heap, because types are easily recognizable and can be located specifically - without the need to create universal memory templates, since everything is known.
Conclusion
1. Specialized Generic Code - Value Types
has the best execution speed, since:
- no heap allocation when copying
- generalized code - you write a function for a specialized type
- no reference counting
- static method dispatch
2. Specialized generalized code - reference types
It has an average execution speed, since:
- allocations per heap when instantiating
- there is a reference count
- dynamic method submission via virtual table
3. Non-specialized generalized code - small values
- no heap allocation - the value is placed in the existential container's value buffer
- no reference counting (since nothing is placed on the heap)
- dynamic method submission via protocol-method table
4. Non-specialized generalized code - large values
- placement on heap - the value is placed in the values ββbuffer
- there is a reference count
- dynamic dispatch via protocol-method table
This material does not mean that classes are bad, structures are good, and structures in combination with generalized code are the best. We want to say that as a programmer, you have the responsibility of choosing a tool for your tasks. Classes are really good when you need to keep large values ββand that there is a semantics of links. Structures are best for small values ββand when you need their semantics. Protocols are best suited to generic code and structures, and so on. All tools are specific to the task that you are solving, and have positive and negative sides.
And also do not pay for dynamism when you do not need it . Find the right abstraction with the least runtime requirements.
- structural types - semantics of meanings
- class types - identity
- generalized code - static polymorphism
- protocol types - dynamic polymorphism
Use indirect storage to work with large values.
And do not forget - it is your responsibility to choose the right tool.
Thank you for your attention to this topic. We hope that these articles have helped you and were interesting.
Good luck