Conf42 Mobile 2022 - Online

Obscure Swift

Video size:

Abstract

Swift helps the developer quite a lot and has many quality of life features that can be leveraged. But sometimes these can bite us in quite unexpected ways.

Pawel will talk about 4 such cases in Swift: - the @autoclosure annotation and a “hidden” retain cycle you can get there - how to turn a Swift struct into an NSObject - default parameters in functions and overriding them in subclasses - and how to have multiple variables of the same name, but different type, in a single object (kind of)

Pawel believes that developers of all levels can benefit from this - newcomers will learn about language features (@autoclosure, default implementation in extension) while at the same time, together with more experienced devs, will get a chance to take a look under the hood and see what powers those features and what seemingly unexpected consequences we may encounter when using them.

Summary

  • Pavo will show you some non obvious aspects of swift. Swift intermediate language is one of the intermediate steps that your high level code gets transformed into before it is finally compiled into binary form. We will take these four things that most of you probably have used or interfaced with already, and do some twisting and turning to try to break them.
  • The interoperability between swift and objective c. Shows how we can store anything in a variables in objective C. Some swift type that aren't classes can be breached into their class counterparts. There are additional checks going on under the hood.
  • What happens if we tried to do all that with our custom types? What do you think? The first one works. It actually has a fixit available that suggests a direct cases to any object. The compiler won't complain and will allow it.
  • Our custom pure swift struct became an NS object. To support interoperability between the languages, there needed to be a way to bridge those anything types between them. The way it's constructed though is quite complex since there are runtime shenanigans happening and some custom memory alignment.
  • Next up, autoclosure. This simple annotation is pretty powers, but also hides a small secret. Can you tell what's the problem with this code? There is a retain cycle here. Handling retain cycles and using quick features is something we do with our eyes closed.
  • Swift allows the possibility to provide default values in function declarations. Is this a bug or a feature? Hard to say. Should it be explicitly disallowed or improved?
  • 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. We'll look at how the default property is synthesized and accessed.
  • 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.

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.
...

Pawel Lopusinski

iOS Developer @ Glovo

Pawel Lopusinski's LinkedIn account Pawel Lopusinski's twitter account



Awesome tech events for

Priority access to all content

Video hallway track

Community chat

Exclusive promotions and giveaways