What is the in-practice difference between generic and protocol-typed function parameters?

i0S Swift Issue

Question or problem with Swift language programming:

Given a protocol without any associated types:

protocol SomeProtocol
{
    var someProperty: Int { get }
}

What is the difference between these two functions, in practice (meaning not “one is generic and the other is not”)? Do they generate different code, do they have different runtime characteristics? Do these differences change when the protocol or functions become non-trivial? (since a compiler could probably inline something like this)

func generic(some: T) -> Int
{
    return some.someProperty
}

func nonGeneric(some: SomeProtocol) -> Int
{
    return some.someProperty
}

I’m mostly asking about differences in what the compiler does, I understand the language-level implications of both. Basically, does nonGeneric imply a constant code size but slower dynamic dispatch, vs. generic using a growing code size per type passed, but with fast static dispatch?

How to solve the problem:

Solution 1:

(I realise that OP is asking less about the language implications and more about what the compiler does – but I feel it’s also worthwhile also to list the general differences between generic and protocol-typed function parameters)

1. A generic placeholder constrained by a protocol must be satisfied with a concrete type

This is a consequence of protocols not conforming to themselves, therefore you cannot call generic(some:) with a SomeProtocol typed argument.

struct Foo : SomeProtocol {
    var someProperty: Int
}

// of course the solution here is to remove the redundant 'SomeProtocol' type annotation
// and let foo be of type Foo, but this problem is applicable anywhere an
// 'anything that conforms to SomeProtocol' typed variable is required.
let foo : SomeProtocol = Foo(someProperty: 42)

generic(some: something) // compiler error: cannot invoke 'generic' with an argument list
                         // of type '(some: SomeProtocol)'

This is because the generic function expects an argument of some type T that conforms to SomeProtocol – but SomeProtocol is not a type that conforms to SomeProtocol.

A non-generic function however, with a parameter type of SomeProtocol, will accept foo as an argument:

nonGeneric(some: foo) // compiles fine

This is because it accepts ‘anything that can be typed as a SomeProtocol‘, rather than ‘a specific type that conforms to SomeProtocol‘.

2. Specialisation

As covered in this fantastic WWDC talk, an ‘existential container’ is used in order to represent a protocol-typed value.

This container consists of:

  • A value buffer to store the value itself, which is 3 words in length. Values larger than this will be heap allocated, and a reference to the value will be stored in the value buffer (as a reference is just 1 word in size).

  • A pointer to the type’s metadata. Included in the type’s metadata is a pointer to its value witness table, which manages the lifetime of value in the existential container.

  • One or (in the case of protocol composition) multiple pointers to protocol witness tables for the given type. These tables keep track of the type’s implementation of the protocol requirements available to call on the given protocol-typed instance.

By default, a similar structure is used in order to pass a value into a generic placeholder typed argument.

  • The argument is stored in a 3 word value buffer (which may heap allocate), which is then passed to the parameter.

  • For each generic placeholder, the function takes a metadata pointer parameter. The metatype of the type that’s used to satisfy the placeholder is passed to this parameter when calling.

  • For each protocol constraint on a given placeholder, the function takes a protocol witness table pointer parameter.

However, in optimised builds, Swift is able to specialise the implementations of generic functions – allowing the compiler to generate a new function for each type of generic placeholder that it’s applied with. This allows for arguments to always be simply passed by value, at the cost of increasing code size. However, as the talk then goes onto say, aggressive compiler optimisations, particularly inlining, can counteract this bloat.

3. Dispatch of protocol requirements

Because of the fact that generic functions are able to be specialised, method calls on generic arguments passed in are able to be statically dispatched (although obviously not for types that use dynamic polymorphism, such as non-final classes).

Protocol-typed functions however generally cannot benefit from this, as they don’t benefit from specialisation. Therefore method calls on a protocol-typed argument will be dynamically dispatched via the protocol witness table for that given argument, which is more expensive.

Although that being said, simple protocol-typed functions may be able to benefit from inlining. In such cases, the compiler is able to eliminate the overhead of the value buffer and protocol and value witness tables (this can be seen by examining the SIL emitted in a -O build), allowing it to statically dispatch methods in the same way as generic functions. However, unlike generic specialisation, this optimisation is not guaranteed for a given function (unless you apply the @inline(__always) attribute – but usually it’s best to let the compiler decide this).

Therefore in general, generic functions are favoured over protocol-typed functions in terms of performance, as they can achieve static dispatch of methods without having to be inlined.

4. Overload resolution

When performing overload resolution, the compiler will favour the protocol-typed function over the generic one.

struct Foo : SomeProtocol {
    var someProperty: Int
}

func bar(_ some: T) {
    print("generic")
}

func bar(_ some: SomeProtocol) {
    print("protocol-typed")
}

bar(Foo(someProperty: 5)) // protocol-typed

This is because Swift favours an explicitly typed parameter over a generic one (see this Q&A).

5. Generic placeholders enforce the same type

As already said, using a generic placeholder allows you to enforce that the same type is used for all parameters/returns that are typed with that particular placeholder.

The function:

func generic(a: T, b: T) -> T {
    return a.someProperty < b.someProperty ? b : a
}

takes two arguments and has a return of the same concrete type, where that type conforms to SomeProtocol.

However the function:

func nongeneric(a: SomeProtocol, b: SomeProtocol) -> SomeProtocol {
    return a.someProperty < b.someProperty ? b : a
}

carries no promises other than the arguments and return must conform to SomeProtocol. The actual concrete types that are passed and returned do not necessarily have to be the same.

Solution 2:

If your generic method had more than one parameter involving T, there would be a difference.

func generic(some: T, someOther: T) -> Int
{
    return some.someProperty
}

In the method above, some and someOther have to be the same type. They can be any type that conforms to SomeProtocol, but they have to be the same type.

However, without generics:

func nonGeneric(some: SomeProtocol, someOther: SomeProtocol) -> Int
{
    return some.someProperty
}

some and someOther can be different types, as long as they conform to SomeProtocol.

Hope this helps!