How does Swift use type erasure and custom detail

  • 2020-06-15 10:21:49
  • OfStack

preface

In the world of Swift, if we call a protocol a king, then generics can be considered queens, and it seems to be extremely difficult to combine the two. Is there a way to combine these two concepts so that they become stepping stones rather than obstacles? The answer is yes, and here we will use the powerful feature Type Erasure.

You may have heard of type erasure and even used the type erasure types provided by the standard library such as AnySequence. But what exactly is type erasing ? How to customize type erasure ? In this article, I'll discuss how to use type erasure and how to customize it. Thanks to Lorenzo Boaro for this topic.

Sometimes you want to hide the specific type of a class, or some implementation details, from external callers. In some cases, this prevents static types from being abused in the project or ensures interaction between types. Type erasure is the process of removing the specific type of a class to make it more generic.

A protocol or abstract parent can be used as one of the simple implementations of type erasure. NSString, for example, is an example. Every time an instance of NSString is created, the object is not a regular NSString object, it is usually an instance of a concrete subclass, which is usually private, and the details are usually hidden. You can use features provided by subclasses without knowing their type, and you don't have to associate your code with their type.

When dealing with Swift generics and associated type protocols, you may need to use 1 of the advanced content. Swift does not allow protocols to be used as concrete types. For example, if you want to write a method whose argument is a sequence containing Int, the following is not true:


func f(seq: Sequence<Int>) { ...

You can't use protocol types this way, it will cause errors at compile time. But you can solve this problem by using generics instead:


func f<S: Sequence>(seq: S) where S.Element == Int { ...

Sometimes this is fine, but there are some tricky situations where you can't add generics in just 1 place: 1 generic function requires more than other generics... Even worse, you can't use generics as return values or attributes. That's a little different from what we thought.


func g<S: Sequence>() -> S where S.Element == Int { ...

We expect the function g to return any type that matches, but unlike this one, it allows the caller to select the type he needs, and then g to provide an appropriate value.

AnySequence is provided in the Swift standard library to help us solve this problem. AnySequence wraps a sequence of any type and erases its type. Using AnySequence to access this sequence, let's rewrite the function f and the function g:


func f(seq: AnySequence<Int>) { ...

func g() -> AnySequence<Int> { ...

The generic part is gone, while the specific type is hidden. Due to the use of AnySequence to wrap specific values, it introduces a definite code complexity and runtime cost. But the code is much cleaner.

Many of these types are available in the Swift standard library, such as AnyCollection, AnyHashable, and AnyIndex. These types are great for customizing generics or protocols, and you can use them directly to simplify your code. Let's explore various ways to implement type erasure.

Class-based type erasure

Sometimes we need to wrap some common functionality from multiple types without exposing type information, which sounds like a superclass-subclass relationship. We can actually use abstract superclasses for type erasure. The parent class provides the API interface, regardless of who implements it. The subclass realizes the corresponding function according to the specific type information.

Next we will customize AnySequence in this way, which we will name MAnySequence:


class MAnySequence<Element>: Sequence {

This class requires an iterator type as the makeIterator return type. We have to do two type erases to hide the underlying sequence type and the iterator type. We defined an Iterator class inside MAnySequence that follows the IteratorProtocol protocol and USES fatalError () to throw exceptions in the next() method. Swift itself does not support abstract types, but that's enough:


class Iterator: IteratorProtocol {
 func next() -> Element? {
 fatalError("Must override next()")
 }
}

The MAnySequence implementation is similar for the makeIterator method. A direct call will throw an exception, which is used to prompt a subclass to override the method:


 func makeIterator() -> Iterator {
 fatalError("Must override makeIterator()")
 }
}

This defines a class-based API for type erasure, and private subclasses will implement these API. A public class is parameterized by an element type, but a private implementation class is parameterized by the sequence type it wraps:


private class MAnySequenceImpl<Seq: Sequence>: MAnySequence<Seq.Element> {

MAnySequenceImpl requires a subclass inherited from Iterator:


class IteratorImpl: Iterator {

IteratorImpl wraps a sequential iterator:


var wrapped: Seq.Iterator

init(_ wrapped: Seq.Iterator) {
 self.wrapped = wrapped
}

Call the wrapped sequence iterator in the next method:


func f<S: Sequence>(seq: S) where S.Element == Int { ...
0

Similarly, MAnySequenceImpl packs 1 sequence:


func f<S: Sequence>(seq: S) where S.Element == Int { ...
1

The function of makeIterator is implemented by getting the iterator from the sequence and returning it wrapped as an IteratorImpl object.


func f<S: Sequence>(seq: S) where S.Element == Int { ...
2

We need a way to actually create these things: add a static method to MAnySequence that creates an instance of MAnySequenceImpl and returns it to the caller as an MAnySequence type.


extension MAnySequence {
 static func make<Seq: Sequence>(_ seq: Seq) -> MAnySequence<Element> where Seq.Element == Element {
 return MAnySequenceImpl<Seq>(seq)
 }
}

In real development, we might do an additional operation to get MAnySequence to provide one initialization method.

Let's try MAnySequence:


func f<S: Sequence>(seq: S) where S.Element == Int { ...
4

Perfect!

Type erasure based on function

Sometimes we want to expose methods that support multiple types, but don't want to specify specific types. A simple solution is to store functions whose signatures are only related to the type we want to expose, and the body of the function is created in the context of the underlying known concrete implementation type.

Let's look at how to design MAnySequence using this approach, similar to the previous implementation. It is a struct and not a class because it is only used as a container and does not need any inheritance.


func f<S: Sequence>(seq: S) where S.Element == Int { ...
5

As before, MAnySequence also requires a returnable iterator (Iterator). The iterator is also designed as a structure, holding one argument null and returning Element? Is actually a function used in the next method of the IteratorProtocol protocol. Next Iterator follows the IteratorProtocol protocol and calls the function in the next method:


func f<S: Sequence>(seq: S) where S.Element == Int { ...
6

MAnySequence is similar to Iterator in that it holds 1 argument that returns a stored property of type Iterator for null. Follow the Sequence protocol and call this property in the makeIterator method.


func f<S: Sequence>(seq: S) where S.Element == Int { ...
7

The constructor for MAnySequence is where the magic comes in, and it takes an arbitrary sequence as an argument:


init<Seq: Sequence>(_ seq: Seq) where Seq.Element == Element {

Next you need to wrap the functionality of this sequence in a constructor:


func f<S: Sequence>(seq: S) where S.Element == Int { ...
9

How do I generate iterators? Request Seq sequence generation:


var iterator = seq.makeIterator()

Next, we wrap the sequence-generated iterator with a custom iteration structure, and the wrapped _next attribute is called in the next() method of the iterator protocol:


   return Iterator(_next: { iterator.next() })
  }
 }
}

Here's how to use MAnySequence:


func printInts(_ seq: MAnySequence<Int>) {
 for elt in seq {
  print(elt)
 }
}

let array = [1, 2, 3, 4, 5]
printInts(MAnySequence(array))
printInts(MAnySequence(array[1 ..< 4]))

Run correctly, great!

This function-based type erasing method is particularly useful when you need to wrap a small piece of functionality into a part of a larger type, so you don't need a separate class to implement this part of the erased type.

Let's say you now want to write code that works for a variety of collection types, but what it really needs to be able to do with those collections is get the count and perform integer subscripts from zero. For example, access to the tableView data source. It might look something like this:


func g<S: Sequence>() -> S where S.Element == Int { ...
3

GenericDataSource other code can manipulate the incoming collection by calling count() or getElement(). And do not let the collection type break the GenericDataSource generic parameter.

conclusion

Type erasure is a very useful technique for preventing generics from invading your code and for keeping your interfaces simple and clean. By wrapping the underlying type, API is separated from the specific functionality. This can be done by using abstract public superclasses and private subclasses or wrapping API in functions. For simple cases where only 1 function is needed, function type-based erasing is extremely effective.

The Swift standard library provides several types of erasure that can be exploited directly. For example, AnySequence wraps one Sequence. As its name suggests, AnySequence allows you to iterate over a sequence without knowing the specific type of the sequence. AnyIterator is also a type eraser and provides an iterator for type erasing. AnyHashable is also a type eraser and provides access to Hashable types. Swift also has a number of collection-based erase types that you can look up by searching Any. The standard library also designs type erasure types for Codable API: KeyedEncodingContainer and KeyedDecodingContainer. They are container protocol type wrappers that can be used to implement Encode and Decode without knowing the underlying specific type information.

conclusion

Mike Ash, link to the original, date: 2017-12-18

rsenjoyer; Proofread: Yousanflics, numbbbbb; Finalized: Forelax


Related articles: