Transcript
This transcript was autogenerated. To make changes, submit a PR.
Hello everyone. I hope you are having a great time at
conf fourty two. Welcome to the obscure part
of the conference where I will show you some non obvious aspects of
swift. So grab a cup of tea or coffee
or a glass of your favorite beverage, and let's lift the obscuring
shroud of mystery. Two words about myself
my name is Pavo and I am currently working at Glovo.
I've been doing iOS related things for around nine years
and I've started using Swift since its third version.
Recently I took a great interest in exploring its underbelly,
or more precisely, the seal level of compilation.
So what is Swift intermediate language?
As the name suggests, it's one of the intermediate steps
that your high level code gets transformed into
before it is finally compiled into binary form.
Apart from applying some optimizations, this step is
also responsible for synthesizing the automatically
generated parts like codable conformances.
So it will take this little structure and turn
it into its final form with synthesized default
conformances of codable requirements. That's pretty
cool if you ask me to work with Seal.
I have been using Seal Inspector by Alex Blevit,
which he built bite some time ago for his stock Swift two
under the hood. It's a pretty simple app, but it
gets the job done. One thing worth noting is
that for me it didn't work when run from Xcode,
but getting the build binary and running it as a standalone app
does the trick and everything works.
Recently I have also learned about compiler Explorer
by Matt Godbolt. It's available via web browser
and it's also open source. It supports multiple languages,
not only Swift, and has some pretty nice features,
especially when working with assembly where it highlights
code and related assembly instructions. That's pretty powerful.
All SIL samples in this presentation are based on the
output of SIL inspector using swift version five five
one running on 2019 Intel MacBook Pro
on macOS bite eleven five okay,
so what does obscure actually mean?
Following definition from Merriam Webster Dictionary,
it's something that is relatively unknown, not clearly
seen, and today we will talk about such things
in the swift language we are using to take these four things
that most of you probably have used or interfaced with already,
do some twisting and turning to try to break them, and finally,
we'll shine light on those seemingly broken parts to
see how and why they actually work.
Our first topic of the day and these subject,
or to be more precise, the interoperability between swift
and objective c. Some parts of it are quite obvious,
some not really, or at least not at first glance,
I'm not going to keep you waiting anymore. Let's dive in to
quickly set the ground for this topic. Let's recap what base
types we have within those languages, and how we can store
anything in a variables in objective C,
everything bar some exceptions like nsproxy inherits
from NS object. As we all know in swift there
is no base type. If you declare a class
without inheritance, then it is just that a standalone
class struct obviously also don't inherit from anything.
Now if you wanted to have a property capable of storing
anything then in objective c you could use
the id type. In Swift there are two
possibilities, any which can hold truly anything,
struct classes closures, and any
object which is dedicated for class types only.
And don't take my word for it. Here is a snippet straight from the
docs. Okay,
so let's see this in action. First line is obvious.
This is our core truth. Any is
any and we can safely assign anything to it.
In this case a plain static integer value.
Now let's see if we can do it using any object.
And unsurprisingly, the compiler throws a nice error
that follows what was explained in the docs. So far so good.
Now, since this part is supposed to be all about
nsobject and has, we know some swift
type that aren't classes can be breached
into their class counterparts. In objective C this
is true, for example for strings and NS strings arrays and
ns arrays and as in our example numbers.
So let's see what happens if we try casting
our integer to NS number. First of all,
it succeeds. This is where the bridging kicks in.
We'll take a deeper look at it in a second.
Let's see, what type does it have? Is it an
NS number or an int? It's both.
So while we moved our integer to class and
objective seaWorld, we still retain information about the underlying
type. Let's check one more thing.
What do you think? Should it be a float?
It is a float. This is kinda okay.
Even though Swift doesn't perform automatic type promotion,
all integers can be converted into floating point numbers.
So let's allow this minor inconsistency with this
bridging, but let's do a quick sanity check.
What about now? Should float object be an int?
Thankfully it's not. There are additional checks going
on under the hood that ensure that in case of NS numbers,
the value when cases to various numeric types in swift
can be properly represented there. And we don't lose precision.
So the checks don't really mean is the original
value of int type, but rather can
the underlying value be represented as an int.
Okay, so how does all this happen?
This is pretty simple. When we cases to NS number,
the compiler is smart enough to simply convert this to
a direct call to a matching NS number initializer
at seal level. Pretty nice as it
turns out, though I haven't shown it in previous slide.
We can also cases our integer directly to NS object.
The results are the same. The dynamic type will be NS
number, though the method used is a little bit different.
Instead of directly going to NS number init,
it rather calls a breaching method on int itself.
Make note of that method, as we will be seeing it again in a
moment. Armed with all that knowledge
from the docs and the quick little experiments we did, let's now
ask the real questions. What happens if we
tried to do all that with our custom types?
What do you think? What should be the result of these
assignments? Obviously the first one works.
It's a class after all. And as the doc stated,
we can freely assign it to any object. And for
these struct we get the same error as before.
But I have a small confession to make. I obscured
part of the error previously. It actually has
a fixit available that suggests a direct cases to
any object. Let's see what happens if we apply it.
The compiler won't complain and will allow it.
Before we dig into why it allowed us to do so,
let's ask another question. A little bizarre
one at first sight. So what do you
think should be the result of those type checks?
Okay, so the first one is false. We didn't inherit our
class from NSobject. We didn't mark it at objective C.
We didn't cast it to anything. That's good.
Let's see about our struct.
What? Why is it an NSobject all of a sudden?
Let's see what happens under the hood. So our
cast to NS object looks like this. In Seal,
it looks like there is a generic method that can bridge anything
to objective c for us at this moment,
Sil stops being useful in this case, since the implementation
of this method is only referenced from there, we are left
with only one choice. Then we need to look at swift source
code. When searching the source for that method,
we find this beautiful comment that explains everything.
If our generic type is a class type, then it basically
gets transferred into objective c as is.
That code may not be able to interact with our class,
but other than that, it's left unchanged. That's why
our check of if NSobject returned false for
our custom class. If our
type conforms to the special private protocol,
then it's breached according to the provided implementation.
This is what we saw when we cast ints and floats into NSobject.
Conformances to this protocol are the powerhouse of
the breaching mechanism. And last but definitely
not least, is our case. If the value cannot be
breached into objective c, it gets boxed in
an objective c class. Which explains why our
custom pure swift struct became an NS
object. The box is simultaneously simple
and complex. Its definition is super simple,
basically just an NSobject subclass.
The way it's constructed though is quite complex
since there are runtime shenanigans happening and some
custom memory alignment. From what I could tell, if you feel
confident reading advanced c plus plus code, I encourage
you to explore that part so we know how
this works, but still a question of why remains.
At the beginning of this part we recapped some info about base
types and how to represent anything. Let's focus
on the anything part to support interoperability
between the languages, there needed to be a way
to bridge those anything types between them,
especially since in objective C a
lot of places relied on the use of id, for example
to represent heterogeneous collections like those under info
dictionaries in NS errors. For example,
before Swift free id was breached
into swift as any object, which makes sense,
both types can be used to hold any class type.
Apparently this created some friction
since if you wanted to use strikes in Swift but
still had to interface with objective C code, you would either need
to refactor your code to use cases or create
a boxing mechanism yourself.
So in swift evolution 116
this behavior was chance. Now id was breached
into swift as any type so we could use our
struct directly without the need to jump through extra hoops.
Basically this has enabled this code to compile just
fine. That struct gets automatically
breached into objective C. If you were to print the contents
of that user info dictionary, you'd get just
the module and type name, unless you conform to custom
string convertible and implemented the description property.
Next up, autoclosure. This simple annotation
is pretty powers, but also hides a small secret
if you aren't careful. Let's take a closer look at it.
Let's start this part with a small quiz of sorts.
Take a look at this code snippet. We have a super simple struct
that takes in an autoclosure and stores it.
The question is what should be the values of call
counter at marked places there is a hint from
Xcode at line 16 on how the signature
of Foo Initializer looks like. Let's take a couple
seconds to consider the snippet.
Okay, so time to show the answers. At line 19
we have zero, and at line 23 we have one.
If that zero feels unexpected to you, then don't worry,
we will see what's going on in a second.
Let's take a look at what the docs say about autoclosures.
So it's an annotation that automatically wraps
our expression in a closure, and that part is key.
It's the entire expression that gets wrapped before it's
evaluated. This is a huge difference at
this point. The obscure part is actually not
technical, but more habitual or perceptive
one. To explain what I mean by that, let's return
to our snippet. Most of us, or so I'd assume,
would expect our snippet and these chance part
to be equivalent. Whenever we see a method being called,
we assume that it happens immediately.
Autoclosure breaks that assumption in a well defined
but a little invisible way. Worst thing, at least
for me, is that Xcode doesn't help with
autocompletion. As you can see on the left side
in the commented line, these autocomplete hints that
this method expects a plain string.
Nothing indicates that it will get wrapped in a clover for
us. Now, at this point, these is all pretty academical,
so to speak, but let's imagine that the full
struct is provided by a closed source framework.
Now, I can easily imagine myself scratching my head and debugging
why wasn't the method called? Or possibly worse,
why was it called more than once? If internals
of that closed source framework did require to evaluate it
more than once to spice things up,
let's adjust our sample snippet a little bit.
Assume that code above framework boundary
is closed source for us, so we also don't see that
the expression is wrapped in a closure. We now moved into
the world of reference semantics, and this sample is
a little step closer to what we could see in real code.
We have a class that handles interactions with some external
SDK, setting it up on init. Can you tell
what's the problem with this code? There is a retain
cycle here. As we learned just a second ago,
the autoclosure annotation wraps the entire expression
in a closure, and since that expression
references a method on self, it also gets implicitly
captured. Once we expand this wrapping,
it becomes really apparent. We see that the SDK
initializer captured self strongly, and we also retain
the SDK strongly.
Fortunately, we are well equipped to deal with these kind
of things. Handling retain cycles and using quick
features is something we do with our eyes closed.
So let's apply this here. For a low price of
a default value, we get a compiler error,
and it makes sense. Our external SDK expects
an expression that returns string, and the
expression we have now returns a closure
that returns a string. Remember,
the entire highlighted part would get wrapped in another closure
so the types don't match.
Now, if we simply called our closure right here,
we'd make the error go away, since now
our autoclosure expression returns these result
of running our closure, which is of an unexpected
type of string. Unfortunately,
this doesn't solve our retain cycle. Let's see why.
Looking at SIL, we see that the closure we defined ourselves
does capture itself weekly as we want it.
But once we take a look at the generated autoclosure,
it still implicitly captured self strongly.
So the compiler was smart enough
to see that our inner closure needs a reference to
self, so the outer autoclosure needs
to capture it at the same time it missed the capture
semantics, defaulting to strong capture.
To fully break this retain cycle, we need to extract our closure
outside of the autoclosure expression.
Checking again with SIL, we see that our extracted closure
still captures self weekly. That's great.
And the generated autoclosure now has
no direct dependency on self, it only captures
another closure type, the one we defined and rightfully
doesn't care what is going on in that
captured closure. To sum up this part,
my aim was definitely not to discourage you from using autoclosure,
but to provide deeper understanding their mechanics.
For me, the worst part about all of this is that
Xcode doesn't hint in any way that in those particular cases,
the expression will not be evaluated directly, but rather
captured for future execution, or no execution at
all. Fortunately, if you are injecting your dependencies
and hiding them behind protocols, then it will be trivial to
detect such cases. If you will generate the protocol based
on public interface, the signatures won't match.
In the end, this newcomers the issue of proper API
design. If you decide to make an autoclosure also
an escaping one, maybe it would be worth naming the
parameter in a way that would indicate that intent
to the end users. As Phil Carton said,
there are only two hard things in computer science, cache invalidation
and name things. Now let's
take a look at a pretty powerful swift feature. The possibility
to provide default values in function declarations.
Consider this simple class. It just prints the date that
is passed to it, and it quite makes sense to
leverage the possibility of adding a default argument to this method
call. Since possibly a lot of places may want to print
current date, so why shouldn't we make it easier for them?
And it works as expected. Now let's
say that for whatever reasons, we may also want to print
epoch date. And since all other parts of our system already
know how to work with date printer, we may decide to subclass
it and provide an overridden implementation of print date together
with new default value to fit the new requirement of the subclass.
Swift allows these, and if we check epoch date printer,
we will see that we get what we want. Now a
small question for you. What will the snippet
print?
Well, first half of the printed statement is correct,
but the second looks wrong, doesn't it? It looks
as if Swift stitched these two methods together.
To understand what is going on, let's check sil first
interesting part could be the method definition. The important
part here to notice is that at seal level,
the default value is not present at method definition
and implementation, though the method expects an
argument of type date. So where does the default
value live? Looking further into seal,
we see that default value is actually defined in a separate place,
and is wrapped in a function that takes no arguments
and produces our expected type. So how does
this all work together? At the call bite, the first
three lines in this last snippet are responsible for
getting our default value, but of that method we just so and
the next two just call the implementation retrieved from the
class. It's important to note that the
class method instruction here retrieves the implementation based
on the dynamic type of the object. So what
chance when we introduce the subclass,
we obviously still have our default argument getter for the cases
class, and unsurprisingly, another one appeared
dedicated for the subclass.
Now the interesting thing that happens is
at the call side, because apparently nothing changed.
The call side still looks exactly the same. If we
look at some earlier parts, though, we'll see that Swift
knew that it was dealing with a subclass and
stored it in a variable typed to a base class.
So what we learned from all this is that while the method to call
is found dynamically, the default value is
still inferred statically based on what type information we
have at the moment of calling the method.
So what to do with it? Is this a
bug or a feature? Hard to say.
Kotlin, for example, explicitly disallows providing
default values in overridden methods, so this short snippet will
throw a nice and descriptive error.
And what about Swift? Should it be explicitly disallowed
or improved? For me, both of those options
sound good. This behavior is around since the
very first versions of Swift, and apparently there was
some will to tackle. This has mentioned in this blog post from
2014, but since that time these mentioned and linked
foreign thread is no longer available. It's definitely one
of those behaviors that may not happen often, but it's
good to have knowledge of it in the back of your mind when it does
occur. Fortunately, these are other approaches
to achieving similar behavior to what was shown in these somewhat
forced code samples, like using protocols instead of inheritance
to support different date printers. Last topic
of the day extensions or more precisely,
protocol extensions. This is again a great language
feature that can help us, but as you probably imagine,
there is a small catch under stamp circumstances.
Let's extend our focus a little bit and let me show
you what I'm talking about. To prepare the
ground for this, let's quickly recap how overloading works
in Swift so we are free to overload
methods, meaning we can have multiple methods with the same
name that differ on these return type or
on the argument types they accept. This isn't
true for properties though. As soon as we create a second
property with the same name, we are getting an
invalid redeclaration error.
Another thing to recap protocol extensions in
objective C we could have marked some protocol requirements with
add optional annotation which allowed conforming types
to not provide implementation for them, but the call bite
had to check if that method was implemented which created some
amount of boilerplate code. Pure Swift
doesn't allow us to make protocol requirements optional in
the same sense as objective seeded, but we can still
create an extension that will provide a default
implementation of a requirement. It may not always
make sense to have a default that hugely depends on
your domain, but when it does you can allow conforming types
to skip implementing those methods. They can still
do it, and if they do, the specialized one will be used.
So let's see how this works with property requirements.
Let us consider this snippet. We have a printer method
that expects a string and just well prints
it. We have a simple value provider with optional
string value and a default implementation for it that returns
nil. Finally, we have a conforming type
with a specialized implementation for that property.
So what do you think? What should be the result
of running this program?
If your intuition said that it should fail to compile because
printer expects a nonopional string and value in
value provider is defined as optional. Then congrats.
You spotted the first tricky part of the sample,
but actually this code does compile
and it will print foo. To understand why,
let's check two things. First, adding two
variables with explicit type, first nonoptional,
the second one optional. This immediately gives us
a hint to what is going on. We actually have two
properties with the same name but different type.
When we thought we overrode the default implementation of value property,
we actually didn't. The compiler inferred
our type to be nonoptional string,
and since it also saw that there is a default implementation
for the optional one, it happily synthesized it for
us. But didn't we just
see that property overloading is not permitted in
swift? Before we dive deeper to see how
this works, let me also show you a second way to access
both properties. Depending on static type
known to the compiler, it will also those either the nonopional
value defined in these struct or the optional one defined in
the protocol and its extension. For the rest of this topic we'll
focus on this part, accessing the property when
we have different information about the object's type.
Now this is a little tricky to show on slides,
but I hope I'll be able to explain this properly.
We'll look at how the default property is synthesized
and accessed, starting from a very simplified example,
and then we'll build our understanding from there. These first
case is the simplest one, no custom overriding.
We just declare the conformance and let the default implementation
do its thing first. Interesting thing in SIL
is the implementation of the default value. It looks
like it's not tied to the conforming type in any way and
lives in its own static context of value provider.
These information is important, so let's keep it handy
and just for reference let's highlight the seal name
of this and these signature. Another important
thing to notice is that at this point there is
no reference to value in terms of our struct,
since it doesn't have that property itself.
So when we try to access the property directly on foo,
the compiler is smart enough to know that the
only candidate is the one from the protocol extension,
so it can optimize a little bit and call that implementation directly.
If we try to access it on the protocol, things get a little
more complicated. First of all, since we lost the
information of the actual type, we need to reach into the
existential to find it again and then find the method
we are looking for. These type information is preserved here
and since we are looking for a witness method,
let's open that existential and look at the witness
table. The witness table is pretty
simple. It defines just the getter for our property,
since that's all that our protocol defines and requires.
The entry in the witness table points at a method that just
calls our default implementation, which makes sense
since it's the only possible candidate to do it.
Next step, we provide a proper implementation of
the protocol requirement by explicitly stating
that we want value to be of optional string type.
So what changed in seal? First of
all, our struct finally got a dedicated getter
for the value property. The call site to get value
directly from foo instance now looks a little different.
It's simple, direct access to a value stored at
an address in memory. And the last part that chance
is the witness method implementation. Now it showed that
there is a magic candidate in the conforming struct,
so instead of calling the default implementation,
it will now call the specialized one.
Finally, by removing the explicit type annotation, we reach
our original sample. The call sites have not chance
from this. Accessing the property directly on foo
still just extracts it from the struct and accessing
it on the protocol still reaches into the existential.
These first change that happened in seal is in the value getter
on our struct. It is still there, obviously, but its type
has changed. It no longer returns an optional string,
but just a plain string. The compiler
inferred the type for us and that change led
to another since there wasn't a matching candidate in
the struct anymore for our protocol requirement,
since the types didn't match the witness method implementation,
those the only other matching candidate.
The default implementation leading to the behavior we
saw earlier and the fact that we have two properties,
there is actually a way to detect this. If you define
your protocol conformance in an extension, you will get
a nice near miss warning, which is great for functions.
But since we cannot have stored properties in extensions,
a pattern that I quite often see, and to be honest, one that I
also use myself, is to provide protocol property requirements
in the cases type definition to be able to fulfill
them using stored properties. While this sample
may seem like something that isn't that probable
to happen in your code, or maybe something that would get caught
during code review, it definitely gets more possible once
the protocol and its default implementation come from a
third party framework and we would be unaware of it at all.
Or once generics get mixed up with this. I encountered
this behavior when a colleague of mine mentioned it on Twitter with a
little more complex code. Sample one involving protocols
with associated type, where the compiler inferred not only
the property type, but also the type for the associated
type, which made the entire thing a lot
trickier. To understand and see what is actually going on,
check out the first link if you are interested to see that sample.
This was raised on the swift forums, where you can also read more
about it. While originally reported as a back
it turns, but it's not one, since this possibility to
do that is required to be supported to allow for
seamless language and library evolution.
And that's all. Thank you for listening.
I hope you found these cases at least half as interesting
as they were for me to investigate and research.
If you'd have any questions, feel free to reach out and chat.
Enjoy the rest of the conference.