Swift Aveiro’s 2020 event was just announced and I thought now would be a good time to convert my workshop from last year’s event to a blog post.
The workshop was divided in two parts: working with pointers and interacting with C libraries. This post covers the first part.
If you’ve ever encountered the dreadful UnsafeMutableRawBufferPointer
or one of its friends and ran to stackoverflow… then this post is for you!
Memory Layout
Before we get started it’s important to understand a few things about memory. Take this Foo
struct for example.
struct Foo {
let a: UInt8
let b: UInt16
}
let t = Foo(a: 42, b: 4321)
It’s laid out in memory like so:
42 | Unused | 4321 | |
0x...100 | 0x...101 | 0x...102 | 0x...103 |
Note how the second byte at address 0x...101
is unused. This happens because of memory alignment.
This article does a pretty good job on explaining this concept but to put it simply: your CPU reads memory in chunks1 and if the value it wants to read spans more than one chunk it has to work harder. So the compiler will lay out the struct’s properties in such a way that avoids that.
You can check the alignment of any given object by using Swift’s MemoryLayout<T>
enum. For example, MemoryLayout<Foo>.alignment
returns 2 which means it must be placed on an even memory address.
Besides alignment there are two other properties on MemoryLayout
that are worth discussing: size and stride.
To understand what these are lets look at another example. Consider the following structs:
struct Bar1 {
let i: Int
let b1: Bool
let b2: Bool
}
struct Bar2 {
let b1: Bool
let b2: Bool
let i: Int
}
struct Bar3 {
let b1: Bool
let i: Int
let b2: Bool
}
All three structs declare the same three properties but in a different order. In Swift an Int
is 8 bytes and a Bool
is 1 byte so one might think the size of any of these structs is 10 (8 + 1 + 1). But because of alignment that’s not actually the case. Here are the actual values of size and stride:
Bar1 | Bar2 | Bar3 | |
---|---|---|---|
Size | 10 | 16 | 17 |
Stride | 16 | 16 | 24 |
Only Bar1
has the expected size of 10. We can already conclude there are more and less efficient ways to declare what’s essentially the same struct. The order of the properties matters! Lets see how each of them is laid out to really understand why this is so.
Bar1:
i | b1 | b2 | Unused | ||||||||||||
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 |
Bar2:
b1 | b2 | Unused | i | ||||||||||||
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 |
Bar3:
b1 | Unused | i | b2 | Unused | |||||||||||||||||||
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 |
Because an Int
has an alignment of 8 and a Bool
has an alignment of 1 (it can be placed anywhere) all the three structs have a different size. The size value is how many bytes we need to store all of the struct’s properties – from the first byte of the first property to the last byte of the last property. If you were to allocate a single Bar1
you only need to allocate 10 bytes of memory, 16 for Bar2
and 17 for Bar3
.
What if you needed an array of Bar1
? That’s where stride comes in. Stride is the how many bytes you need for each item of the array. Stride is different from size because the alignment of any of these structs is 8 (the largest alignment value of its properties). Which means they must be placed on an address which is a multiple of 8.
So if you needed to allocate an array of 10 Bar1
structs you’d need 10 times the stride (10 * 16) which is 160 bytes. For Bar3
it would take 240
, that’s ~50% less efficient.
So far we’ve only looked at structs but it’s interesting to see what the memory layout a class type is.
class Foo {
let a: UInt8
let b: UInt16
}
MemoryLayout<Foo>.size // 8
MemoryLayout<Foo>.alignment // 8
MemoryLayout<Foo>.stride // 8
As expected, because classes are reference types, their memory layout is simply the layout of a pointer.
“Unsafe” pointers
Now let’s look at “unsafe” pointers in Swift. We’ll look at both raw and typed pointers. But first a word about the why Swift prefixes this API with “Unsafe”.
That prefix is there to let you know that the compiler doesn’t have your back while you’re dealing with these pointers. And that it’s easy to shoot yourself in the foot. If you have any C experience you probably wouldn’t call this unsafe, you’d simply call it programming. Needless to say it is perfectly feasible to write correct and safe code using this API.
Raw pointers
Raw pointers are simply pointers to a chunk of memory. These pointers have no knowledge of what’s stored in that chunk. To them it’s just bytes. There are four of them: UnsafeRawPointer
, UnsafeMutableRawPointer
, UnsafeRawBufferPointer
and UnsafeMutableRawBufferPointer
.
The UnsafeRawPointer
points to an immutable chunk of memory. If you need mutation then you need to use the UnsafeMutableRawPointer
.
The buffer variants are similar except they treat the memory as a collection of bytes. And when I say “collection” I mean the Swift’s Collection
protocol.
Lets see how we could create an array of 4 integers using raw pointers.
|
|
On line 5 we allocate the space for the array. Note how we use stride
instead of size
to calculate the total size of the array2.
After that, on line 9, we advance the pointer in multiples of stride
to store the integer value for that position.
Whenever we write (line 10) or read (line 15) using a raw pointer we need to tell it what the type is so it knows how to handle the underlying bytes. By specifying the type it knows how much to read/write, and in this particular case of an Int
it will also handle endianness for us.
Running this code on a playground outputs:
Int at 0: 0
Int at 1: 1
Int at 2: 4
Int at 3: 9
An awful lot of work to do the equivalent of Array(0..<4).map { $0*$0 }
isn’t it?
Typed pointers
Before we talk about typed pointers we need to learn about memory states first. A pointer can be in one of 4 states:
Unbound | Bound | |
---|---|---|
Uninitialised | Unbound / Uninitialised | Bound / Uninitialised |
Initialised | Unbound / Initialised | Bound / Initialised |
So far we’ve only used raw pointers, so we’ve only been in the unbound and uninitialised state. Typed pointers on the other hand are bound to a type.
Just like their raw counterparts, there are four typed pointers: UnsafePointer<T>
, UnsafeMutablePointer<T>
, UnsafeBufferPointer<T>
and UnsafeMutableBufferPointer<T>
.
You can think of using a typed pointer as embedding the information of the underlying type in the pointer itself. Instead of using the load
and store
methods of the raw pointer you used the pointee
var to read and write from/to memory in a type safe manner.
Lets take our previous pointer to 4 integers, which is an UnsafeMutableRawPointer
, and bind it to the Int
type to get back an UnsafeMutablePointer<Int>
to work with. We can then use it to double all items of the array:
|
|
Using typed pointers instead of raw pointers brings many benefits, for instance they:
- Abstract away memory layout. Note how on line 4 we get a pointer to the integer at the current index simply by using pointer arithmetic. No need to think about stride when using typed pointers.
- Provide type safety. Replacing line 6 with
idxIntPtr.pointee = "2"
would result in a compiler error. - Manage reference counting when needed.
That last one is important, and is our segue to talk about pointer initialisation.
It’s a lot easier to understand how initialisation is related to memory management if we look at another example.
|
|
Lets go over what’s happening here line by line.
- First we declare our class
Foo
. Classes are reference types so they participate in reference counting. - On line 7 we allocate a typed pointer capable of storing two instances of
Foo
. Or to put it more precisely, they are capable of storing the address (pointers) of two instances ofFoo
. - On line 8 we initialise the pointer by having both addresses point to a single instance of
Foo(0)
. At this point,Foo(0)
has a reference count of 2. - On line 9, we replace the first address to point to a new instance of
Foo(1)
. This single assignment will automatically decrement the reference count ofFoo(0)
. At this point, bothFoo(0)
andFoo(1)
have a reference count of 1. - On line 10, we replace the first address to point to a new instance of
Foo(1)
. Again the reference count ofFoo(0)
is decremented. At this point,Foo(0)
is deallocated andFoo(1)
andFoo(2)
have a reference count of 1. - On line 11 we deinitialize the pointer. Causing both the reference counts of both
Foo(1)
andFoo(2)
to be decremented. Since the reference counts are now 0, both objects are deallocated. - Finally, on line 12 we deallocate the pointer itself.
Who said pointers weren’t fun?
If you have comments, questions or praise about this post I’d love to hear it on Twitter!