7 Swift gotchas and how to avoid them

7 Swift gotchas and how to avoid them

[[163025]]

Swift is accomplishing an amazing feat, it is changing the way we program on Apple devices, introducing many modern paradigms such as functional programming and richer type checking than pure object-oriented languages ​​like Objective-C.

Swift aims to help developers avoid bugs by adopting safe programming patterns. However, this will inevitably lead to some artificial traps, which will introduce bugs without the compiler reporting an error. Some of these traps are already mentioned in the Swift book, and some are not. Here are seven traps I encountered in the past year, involving Swift protocol extensions, optional chaining, and functional programming.

Protocol extensions: powerful but need to be used with caution

The ability for a Swift class to inherit from another class is powerful. Inheritance makes specific relationships between classes clearer and supports fine-grained code sharing. However, in Swift, if it is not a reference type (such as a structure or enumeration), there can be no inheritance relationship. However, a value type can inherit a protocol, and a protocol can inherit another protocol. Although protocols cannot contain other code besides type information, protocol extensions can contain code. In this way, we can use an inheritance tree to achieve code sharing, where the leaves of the tree are value types (structures or enumeration classes), and the interior and root of the tree are protocols and their corresponding extensions.

But the implementation of Swift protocol extensions is still new and unexplored territory, and there are still some problems. The code does not always perform as we expect. Because these problems occur when value types (structures and enumerations) are used in combination with protocols, we will use the example of combining classes with protocols to show that there are no traps in this scenario. Surprising things will happen when we switch back to using value types and protocols.

Let’s start with our example: classy pizza

Suppose there are three pizzas made with two different grains:

  1. enum Grain { case Wheat, Corn }
  2.   
  3. class NewYorkPizza { let crustGrain: Grain = .Wheat }
  4. class ChicagoPizza { let crustGrain: Grain = .Wheat }
  5. class CornmealPizza { let crustGrain: Grain = .Corn }

We can get the raw materials corresponding to the pizza through the crustGrain attribute

  1. NewYorkPizza().crustGrain // returns Wheat  
  2. ChicagoPizza().crustGrain // returns Wheat  
  3. CornmealPizza().crustGrain // returns Corn  

Since most pizzas are made from wheat, this common code can be put into a superclass as the default code to be executed.

  1. enum Grain { case Wheat, Corn }
  2.   
  3. class Pizza {
  4. var crustGrain: Grain { return .Wheat }
  5. // other common pizza behavior  
  6. }
  7. class NewYorkPizza: Pizza {}
  8. class ChicagoPizza: Pizza {}

These default codes can be overridden to handle other situations (made with corn)

  1. class CornmealPizza: Pizza {
  2. override var crustGain: Grain { return .Corn }
  3. }

Oops! This code is wrong, and fortunately the compiler caught these errors. Can you spot the error? We forgot to write r in the second crustGain. Swift avoids this error by explicitly annotating override. For example, in this example, we use override, but the misspelled "crustGain" does not actually override any property. Here is the modified code:

  1. class CornmealPizza: Pizza {
  2. override var crustGrain: Grain { return .Corn }
  3. }

Now it compiles and runs successfully:

  1. NewYorkPizza().crustGrain // returns Wheat  
  2. ChicagoPizza().crustGrain // returns Wheat  
  3. CornmealPizza().crustGrain // returns Corn  

At the same time, the Pizza superclass allows our code to operate on pizzas without knowing the specific type of Pizza. We can declare a variable of type Pizza.

  1. var pie: Pizza

But the generic type Pizza can still get information about specific types.

  1. pie = NewYorkPizza(); pie.crustGrain // returns Wheat  
  2. pie = ChicagoPizza(); pie.crustGrain // returns Wheat  
  3. pie = CornmealPizza(); pie.crustGrain // returns Corn  

Swift's reference type works well in this demo. But if this program involves concurrency and race conditions, we can use value types to avoid them. Let's try Pizza of value type!

This is as simple as above, just change the class to struct:

  1. enum Grain { case Wheat, Corn }
  2.   
  3. struct NewYorkPizza { let crustGrain: Grain = .Wheat }
  4. struct ChicagoPizza { let crustGrain: Grain = .Wheat }
  5. struct CornmealPizza { let crustGrain: Grain = .Corn }

implement

  1. NewYorkPizza() .crustGrain // returns Wheat  
  2. ChicagoPizza() .crustGrain // returns Wheat  
  3. CornmealPizza() .crustGrain // returns Corn  

When we use reference types, we achieve this through a superclass Pizza, but for value types this will require a protocol and a protocol extension to work together.

  1. protocol Pizza {}
  2.   
  3. extension Pizza { var crustGrain: Grain { return .Wheat } }
  4.   
  5. struct NewYorkPizza: Pizza { }
  6. struct ChicagoPizza: Pizza { }
  7. struct CornmealPizza: Pizza { let crustGain: Grain = .Corn }

This code can be compiled, let's test it:

  1. NewYorkPizza().crustGrain // returns Wheat  
  2. ChicagoPizza().crustGrain // returns Wheat  
  3. CornmealPizza().crustGrain // returns Wheat What?!  

As for the execution result, we want to say that cornmeal pizza is not made with Wheat, but the returned result is wrong! Oops! I put

  1. struct CornmealPizza: Pizza { let crustGain: Grain = .Corn }

In the code above, crustGrain is written as crustGain, and we forget the r again. However, there is no override keyword for value types to help the compiler find our mistake. Without the help of the compiler, we have to be more careful when writing code.

Be careful when overriding protocol properties in protocol extensions

Ok, let's correct this spelling mistake:

  1. struct CornmealPizza: Pizza { let crustGrain: Grain = .Corn }

Re-execution

  1. NewYorkPizza().crustGrain // returns Wheat  
  2. ChicagoPizza().crustGrain // returns Wheat  
  3. CornmealPizza().crustGrain // returns Corn Hooray!  

In order to talk about pizza without worrying about whether it is New York, Chicago, or cornmeal, we can use the Pizza protocol as the type of the variable.

  1. var pie: Pizza

This variable can be used in different types of Pizza

  1. pie = NewYorkPizza(); pie.crustGrain // returns Wheat  
  2. pie = ChicagoPizza(); pie.crustGrain // returns Wheat  
  3. pie = CornmealPizza(); pie.crustGrain // returns Wheat Not again?!  

Why does this program show that cornmeal pizza contains wheat? Swift ignores the current actual value of variables when compiling code. The code can only use information known at compile time, and has no knowledge of specific information at run time. The information available to the program at compile time is that pie is of type pizza, and the pizza protocol extension returns wheat, so the override in the CornmealPizza struct has no effect. Although the compiler can warn about potential errors when replacing dynamic dispatch with static dispatch, it actually does not do so. Carelessness here will lead to huge pitfalls.

In this case, Swift provides a solution. In addition to defining the crustGrain property in a protocol extension, you can also declare it in the protocol.

  1. protocol Pizza { var crustGrain: Grain { get } }
  2. extension Pizza { var crustGrain: Grain { return .Wheat } }

Declaring the variable inside the protocol and defining it in the protocol extension tells the compiler to pay attention to the runtime value of the variable pie.

A property declaration in a protocol has two different meanings, static or dynamic dispatch, depending on whether the property is defined in a protocol extension.

After adding the declaration of the variables in the protocol, the code can run normally:

  1. pie = NewYorkPizza(); pie.crustGrain // returns Wheat  
  2. pie = ChicagoPizza(); pie.crustGrain // returns Wheat  
  3. pie = CornmealPizza(); pie.crustGrain // returns Corn Whew!  

Each property defined in a protocol extension needs to be declared in the protocol.

However, this approach of trying to avoid traps does not always work.

Imported protocols are not fully extensible.

A framework (library) allows a program to import an interface to use without having to include the relevant implementation. For example, Apple provides us with the necessary frameworks to implement user experience, system facilities and other functions. Swift extensions allow programs to add their own properties to imported classes, structures, enumerations and protocols (the properties here are not storage properties). Properties added through protocol extensions are as if they were originally in the protocol. But in fact, properties defined in protocol extensions are not first-class citizens, because property declarations cannot be added through protocol extensions.

We first implement a framework that defines the Pizza protocol and specific types

  1. // PizzaFramework:  
  2.   
  3. public protocol Pizza { }
  4.   
  5. public struct NewYorkPizza: Pizza { public init() {} }
  6. public struct ChicagoPizza: Pizza { public init() {} }
  7. public struct CornmealPizza: Pizza { public init() {} }

Import the framework and extend Pizza

  1. import PizzaFramework
  2.   
  3. public   enum Grain { case Wheat, Corn }
  4.   
  5. extension Pizza { var crustGrain: Grain { return .Wheat } }
  6. extension CornmealPizza { var crustGrain: Grain { return .Corn } }

As before, static dispatch produces a wrong answer

  1. var pie: Pizza = CornmealPizza()
  2. pie.crustGrain // returns Wheat Wrong!  

This is because (same as explained above) the crustGrain property is not declared in the protocol, but only defined in the extension. However, we have no way to modify the framework code, so we cannot solve this problem. Therefore, it is unsafe to add protocol properties of other frameworks through extensions.

Do not extend imported protocols to add new properties that may require dynamic dispatch.

As just described, the interaction between frameworks and protocol extensions limits the usefulness of protocol extensions, but frameworks are not the only limiting factor. Similarly, type constraints are also not conducive to protocol extensions.

Attributes in restricted protocol extensions: declaration is no longer enough

Let’s review the Pizza example from earlier:

  1. enum Grain { case Wheat, Corn }
  2.   
  3. protocol Pizza { var crustGrain: Grain { get } }
  4. extension Pizza { var crustGrain: Grain { return .Wheat } }
  5.   
  6. struct NewYorkPizza: Pizza { }
  7. struct ChicagoPizza: Pizza { }
  8. struct CornmealPizza: Pizza { let crustGrain: Grain = .Corn }

Let's make a meal out of Pizza. Unfortunately, not every meal is pizza, so we use a generic Meal structure to accommodate various situations. We only need to pass in a parameter to determine the specific type of meal.

  1. struct Meal: MealProtocol {
  2. let mainDish: MainDishOfMeal
  3. }

The Meal structure inherits from the MealProtocol protocol, which can test whether a meal contains gluten.

  1. protocol MealProtocol {
  2. typealias MainDish_OfMealProtocol
  3. var mainDish: MainDish_OfMealProtocol {get}
  4. var isGlutenFree: Bool {get}
  5. }

To avoid poisoning, the code uses the default value (gluten-free)

  1. extension MealProtocol {
  2. var isGlutenFree: Bool { return   false }
  3. }

Where in Swift provides a way to express constraint protocol extensions. When the main dish is pizza, we know that pizza has a scrustGrain property, so we can access this property. Without the restriction of where, it is unsafe to access scrustGrain when it is not a pizza.

  1. extension MealProtocol where MainDish_OfMealProtocol: Pizza {
  2. var isGlutenFree: Bool { return mainDish.crustGrain == .Corn }
  3. }

An extension with a Where clause is called a constrained extension.

Let's make a delicious cornmeal Pizza

  1. let meal: Meal = Meal(mainDish: CornmealPizza())

result:

  1. meal.isGlutenFree // returns false  
  2. // According to the protocol extension, theoretically it should return true  

As we demonstrated in the previous section, when dynamic dispatch occurs, we should declare it in the protocol and define it in the protocol extension. But the definition of constraint extensions is always statically dispatched. To prevent bugs caused by accidental static dispatch:

If a new property requires dynamic dispatch, avoid using a constrained protocol extension.

Using optional chaining with side effects

Swift can avoid errors by statically checking whether a variable is nil, and using a convenient shorthand expression, optional chaining, to ignore the possibility of nil. This is also the default behavior of Objective-C.

Unfortunately, if the reference being assigned in optional chaining could be null, this could lead to errors. Consider the following code, where a Holder stores an integer:

  1. class Holder {
  2. var x = 0  
  3. }
  4.   
  5. var n = 1  
  6. var h: Holder? = nil
  7. h?.x = n++

In the first line of this code, we assign n++ to the attribute of h. In addition to the assignment, the variable n is incremented, which we call a side effect.

The final value of variable n depends on whether h is nil. If h is not nil, then the assignment statement is executed, and n++ is also executed. But if h is nil, not only the assignment statement will not be executed, but n++ will also not be executed. In order to avoid surprising results caused by no side effects, we should:

Avoid assigning the result of an expression with side effects to the variable on the left side of the equal sign through optional chaining.

Functional Programming Pitfalls

Thanks to Swift, the benefits of functional programming have been brought to Apple's ecosystem. Functions and closures in Swift are first-class citizens, which are not only convenient and easy to use but also powerful. Unfortunately, there are also some pitfalls that we need to be careful to avoid.

For example, inout parameters will silently become invalid in closures.

Swift's inout parameter allows a function to accept a parameter and assign it directly, and Swift's closure supports referencing captured functions during execution. These features help us write elegant and readable code, so you may use them together, but this combination may cause problems.

We override the crustGrain property to illustrate the use of inout parameters, starting with no closure for simplicity:

  1. enum Grain {
  2. case Wheat, Corn
  3. }
  4.   
  5. struct CornmealPizza {
  6. func setCrustGrain(inout grain: Grain) {
  7. grain = .Corn
  8. }
  9. }

To test this function, we pass it a variable as a parameter. After the function returns, the value of this variable should change from Wheat to Corn:

  1. let pizza = CornmealPizza()
  2. var grain: Grain = .Wheat
  3. pizza.setCrustGrain(&grain)
  4. grain // returns Corn  

Now let's try returning the closure from the function and then setting the value of the parameter in the closure:

  1. struct CornmealPizza {
  2. func getCrustGrainSetter() -> (inout grain: Grain) -> Void {
  3. return { (inout grain: Grain) in
  4. grain = .Corn
  5. }
  6. }
  7. }

Using this closure only requires one more call:

  1. var grain: Grain = .Wheat
  2. let pizza = CornmealPizza()
  3. let aClosure = pizza.getCrustGrainSetter()
  4. grain // returns Wheat (We have not run the closure yet)  
  5. aClosure(grain: &grain)
  6. grain // returns Corn  

So far so good, but what if we pass the arguments directly into the getCrustGrainSetter function instead of a closure?

  1. struct CornmealPizza {
  2. func getCrustGrainSetter(inout grain: Grain) -> () -> Void {
  3. return { grain = .Corn }
  4. }
  5. }

Then try again:

  1. var grain: Grain = .Wheat
  2. let pizza = CornmealPizza()
  3. let aClosure = pizza.getCrustGrainSetter(&grain)
  4. print(grain) // returns Wheat (We have not run the closure yet)  
  5. aClosure()
  6. print(grain) // returns Wheat What?!?  

inout parameters become invalid when passed outside the scope of the closure, so:

Avoid using in-out parameters in closures

This issue is mentioned in the Swift documentation, but there is another related issue worth noting, which is related to the equivalent method of creating closures: currying.

When using currying, inout parameters appear inconsistent.

In a function that creates and returns a closure, Swift provides a concise syntax for the function’s type and body. Although this currying may seem like a shorthand expression, it can be a bit surprising when used in conjunction with inout parameters. To illustrate this, let’s implement the example above using currying syntax. Instead of declaring the function to return a closure, the function adds a second parameter list after the first, and then omits the explicit closure creation in the function body:

  1. struct CornmealPizza {
  2. func getCrustGrainSetterWithCurry(inout grain: Grain)() -> Void {
  3. grain = .Corn
  4. }
  5. }

Just like when creating a closure explicitly, we call this function and return a closure:

  1. var grain: Grain = .Wheat
  2. let pizza = CornmealPizza()
  3. let aClosure = pizza.getCrustGrainSetterWithCurry(&grain)

In the example above, the closure was explicitly created but failed to assign a value to the inout parameter, but this time it succeeds:

  1. aClosure()
  2. grain // returns Corn  

This shows that inout parameters work fine in curried functions, but not when explicitly creating closures.

Avoid using inout parameters in curried functions, as this code will fail if you later change the currying to explicitly create closures.

Summary: Seven things to avoid

  • Be careful when overriding protocol properties in protocol extensions

  • Each property defined in the protocol extension needs to be declared in the protocol

  • Do not extend the properties of imported third-party protocols, as that may require dynamic dispatch

  • If a new property requires dynamic dispatch, avoid using constrained protocol extensions

  • Avoid assigning the result of an expression with side effects to the variable on the left side of the equal sign through optional chaining.

  • Avoid using inout parameters in closures

  • Avoid using inout parameters in curried functions, as this code will fail if you later change the currying to explicitly create closures.

<<:  Value test: Are domain names still linked to brands today?

>>:  Best Android Hacking Tools of 2016

Recommend

March tax filing extension! When can the application be extended?

Due to the impact of the new coronavirus, many co...

Exclusive analysis: 4 ways to monetize mobile Internet! (Down)

Before the analysis, let me briefly explain my de...

The 5 most easily overlooked points and solutions for short video operations

What is the happiest thing about short video oper...

Dutch prosecutors formally investigate Suzuki and Jeep emissions cheating

According to Reuters, on July 10, Dutch prosecuto...

CES2015: The curved G3 is here! LG releases the Flex 2 phone

In the early morning of January 6, LG released it...

How to monetize short videos in the medical and health field?

In the past few months, we have participated in t...

ColorOS 11 based on Android 11 officially announced: released on September 24

Following the launch of Android 11 Beta1 version ...