Home iOS & Swift Books Swift Apprentice

18
Access Control, Code Organization & Testing Written by Eli Ganim

You declare Swift types with properties, methods, initializers and even other nested types. These elements make up the interface to your code or the API (Application Programming Interface).

As code grows in complexity, controlling this interface becomes an important part of software design. You may wish to create methods that serve as “helpers” to your code, or properties that keep track of internal states that you don’t want as part of your code’s interface.

Swift solves these problems with a feature area known as access control, which lets you control your code’s viewable interface. Access control enables you, the library author, to hide implementation complexity from users.

This hidden internal state is sometimes referred to as the invariant, which your public interface should always maintain. Preventing direct access to the internal state and keeping the invariant valid is a fundamental software design concept known as encapsulation. In this chapter, you will learn what access control is, the problems it solves, and how to apply it.

Problems introduced by lack of access control

Imagine for a moment you are writing a banking library. This library would help serve as the foundation for your customers (other banks) to write their banking software.

In a playground, start with the following protocol:

/// A protocol describing core functionality for an account
protocol Account {
  associatedtype Currency

  var balance: Currency { get }
  func deposit(amount: Currency)
  func withdraw(amount: Currency)
}

This code contains Account, a protocol that describes what any account should have — the ability to deposit, withdraw, and check the balance of funds.

Now add a conforming type with the code below:

typealias Dollars = Double

/// A U.S. Dollar based "basic" account.
class BasicAccount: Account {

  var balance: Dollars = 0.0

  func deposit(amount: Dollars) {
    balance += amount
  }

  func withdraw(amount: Dollars) {
    if amount <= balance {
      balance -= amount
    } else {
      balance = 0
    }
  }
}

This conforming class, BasicAccount, implements deposit(amount:) and withdraw(amount:) by simply adding or subtracting from the balance (typed in Dollars, an alias for Double). Although this code is very straightforward, you may notice a slight issue. The balance property in the Account protocol is read-only — in other words, it only has a get requirement.

However, BasicAccount implements balance as a variable that is both readable and writeable.

Nothing can prevent other code from directly assigning new values for balance:

// Create a new account
let account = BasicAccount()

// Deposit and withdraw some money
account.deposit(amount: 10.00)
account.withdraw(amount: 5.00)

// ... or do evil things!
account.balance = 1000000.00

Oh no! Even though you carefully designed the Account protocol to only be able to deposit or withdraw funds, the implementation details of BasicAccount make it possible for outside to change the internal state arbitrarily.

Fortunately, you can use access control to limit the scope at which your code is visible to other types, files or even software modules!

Note: Access control is not a security feature that protects your code from malicious hackers. Instead, it lets you express intent by generating helpful compiler errors if a user attempts directly access implementation details that may compromise the invariant, and therefore, correctness.

Introducing access control

You can add access modifiers by placing a modifier keyword in front of a property, method or type declaration.

private(set) var balance: Dollars

Private

The private access modifier restricts access to the entity it is defined in and any nested type within it — also known as the “lexical scope”. Extensions on the type within the same source file can also access the entity.

class CheckingAccount: BasicAccount {
  private let accountNumber = UUID().uuidString

  class Check {
    let account: String
    var amount: Dollars
    private(set) var cashed = false

    func cash() {
      cashed = true
    }

    init(amount: Dollars, from account: CheckingAccount) {
      self.amount = amount
      self.account = account.accountNumber
    }
  }
}
func writeCheck(amount: Dollars) -> Check? {
  guard balance > amount else {
    return nil
  }

  let check = Check(amount: amount, from: self)
  withdraw(amount: check.amount)
  return check
}

func deposit(_ check: Check) {
  guard !check.cashed else {
    return
  }

  deposit(amount: check.amount)
  check.cash()
}
// Create a checking account for John. Deposit $300.00
let johnChecking = CheckingAccount()
johnChecking.deposit(amount: 300.00)

// Write a check for $200.00
let check = johnChecking.writeCheck(amount: 200.0)!

// Create a checking account for Jane, and deposit the check.
let janeChecking = CheckingAccount()
janeChecking.deposit(check)
janeChecking.balance // 200.00

// Try to cash the check again. Of course, it had no effect on
// Jane’s balance this time :]
janeChecking.deposit(check)
janeChecking.balance // 200.00

Playground sources

Before jumping into the rest of this chapter, you’ll need to learn a new Swift playground feature: source files.

Fileprivate

Closely related to private is fileprivate, which permits access to any code written in the same file as the entity, instead of the same lexical scope and extensions within the same file that private provides.

private init(amount: Dollars, from account: CheckingAccount) { //...
fileprivate init(amount: Dollars, from account: CheckingAccount) { //...

Internal, public and open

With private and fileprivate, you could protect code from being accessed by other types and files. These access modifiers modified access from the default access level of internal.

Internal

Back in your playground, uncomment the code that handles John writing checks to Jane:

// Create a checking account for John. Deposit $300.00
let johnChecking = CheckingAccount()
johnChecking.deposit(amount: 300.00)
// ...

Public

To make CheckingAccount visible to your playground, you’ll need to change the access level from internal to public. An entity that is public can be seen and used by code outside the module in which it’s defined.

public class CheckingAccount: BasicAccount {
public class BasicAccount: Account

// In BasicAccount:
public init() { }

// In CheckingAccount:
public override init() { }

Open

Now that CheckingAccount and its public members are visible to the playground, you can use your banking interface as designed.

class SavingsAccount: BasicAccount {
  var interestRate: Double

  init(interestRate: Double) {
    self.interestRate = interestRate
  }

  func processInterest() {
    let interest = balance * interestRate
    deposit(amount: interest)
  }
}

open class BasicAccount: Account { //..
override func deposit(amount: Dollars) {
    // LOL
    super.deposit(amount: 1_000_000.00)
}

Mini-exercises

  1. Create a struct Person in a new Sources file. This struct should have first, last and fullName properties that are readable but not writable by the playground.
  2. Create a similar type, except make it a class and call it ClassyPerson. In the playground, subclass ClassyPerson with class Doctor and make a doctor’s fullName print the prefix "Dr.".

Organizing code into extensions

A theme of access control is the idea that your code should be loosely coupled and highly cohesive. Loosely coupled code limits how much one entity knows about another, which in turn makes different parts of your code less dependent on others. As you learned earlier, highly cohesive code helps closely related code work together to fulfill a task.

Extensions by behavior

An effective strategy in Swift is to organize your code into extensions by behavior. You can even apply access modifiers to extensions themselves, which will help you categorize entire code sections as public, internal or private.

private var issuedChecks: [Int] = []
private var currentCheck = 1
private extension CheckingAccount {
  func inspectForFraud(with checkNumber: Int) -> Bool {
    issuedChecks.contains(checkNumber)
  }

  func nextNumber() -> Int {
    let next = currentCheck
    currentCheck += 1
    return next
  }
}

Extensions by protocol conformance

Another effective technique is to organize your extensions based on protocol conformance. You’ve already seen this technique used in Chapter 16, “Protocols”. As an example, let’s make CheckingAccount conform to CustomStringConvertible by adding the following extension:

extension CheckingAccount: CustomStringConvertible {
  public var description: String {
    "Checking Balance: $\(balance)"
  }
}

available()

If you take a look at SavingsAccount, you’ll notice that you can abuse processInterest() by calling it multiple times and repeatedly adding interest to the account. To make this function more secure, you can add a PIN to the account.

class SavingsAccount: BasicAccount {
  var interestRate: Double
  private let pin: Int
  
  init(interestRate: Double, pin: Int) {
    self.interestRate = interestRate
    self.pin = pin
  }
  
  func processInterest(pin: Int) {
    if pin == self.pin {
      let interest = balance * interestRate
      deposit(amount: interest)
    }
  }
}
@available(*, deprecated, message: "Use init(interestRate:pin:) instead")
@available(*, deprecated, message: "Use processInterest(pin:) instead")

Opaque return types

Imagine you need to create a public API for users of your banking library. You’re required to make a function called createAccount that creates a new account and returns it. One of the requirements of this API is to hide implementation details so that clients are encouraged to write generic code. It means that you shouldn’t expose the type of account you’re creating, be it a BasicAccount, CheckingAccount or SavingsAccount. Instead you’ll just return some instance that conforms to the protocol Account.

func createAccount() -> Account {
  CheckingAccount()
}

func createAccount() -> some Account {
  CheckingAccount()
}

Swift Package Manager

Another powerful way to organize your code is to use Swift Package Manager, or SwiftPM for short. SwiftPM lets you “package” your module so that you or other developers can use it in their code with ease.

Testing

Imagine new engineers join your team to work on your banking library. These engineers are tasked with updating the SavingsAccount class to support taking loans. For that, they will need to update the basic functionally of the code you’ve written. This change is risky since they’re not familiar with the code, and their changes might introduce bugs to the existing logic. An excellent way to prevent this from happening is to write unit tests.

Creating a test class

To write unit tests, you first need to import the XCTest framework. Add this at the top of the playground:

import XCTest
class BankingTests: XCTestCase {
}

Writing tests

Once you have your test class ready, it’s time to add some tests. Tests should cover the core functionality of your code and some edge cases. The acronym FIRST describes a concise set of criteria for useful unit tests. Those criteria are:

func testSomething() {
}
BankingTests.defaultTestSuite.run()
Test Suite 'BankingTests' started at ...
Test Case '-[__lldb_expr_2.BankingTests testSomething]' started.
Test Case '-[__lldb_expr_2.BankingTests testSomething]' passed (0.837 seconds).
Test Suite 'BankingTests' passed at ...
   Executed 1 test, with 0 failures (0 unexpected) in 0.837 (0.840) seconds

XCTAssert

XCTAssert functions ensure your tests meet certain conditions. For example, you can verify that a certain value is greater than zero or that an object isn’t nil. Here’s an example of how to check that a new account starts with zero balance. Replace the testSomething method with this:

func testNewAccountBalanceZero() {
  let checkingAccount = CheckingAccount()
  XCTAssertEqual(checkingAccount.balance, 0)
}
Test Case '-[__lldb_expr_4.BankingTests testNewAccountBalanceZero]' started.
Test Case '-[__lldb_expr_4.BankingTests testNewAccountBalanceZero]' passed (0.030 seconds).
public private(set) var balance: Dollars = 0.0
error: -[BankingTests testNewAccountBalanceZero] : XCTAssertEqual failed: ("1.0") is not equal to ("0.0")
func testCheckOverBudgetFails() {
    let checkingAccount = CheckingAccount()
    let check = checkingAccount.writeCheck(amount: 100)
    XCTAssertNil(check)
}

XCTFail and XCTSkip

If a certain pre-condition isn’t met, you can opt to fail the test. For example, suppose you’re writing a test to verify an API that’s only available on iOS 14 and above. In that case, you can fail the test for iOS simulators running older versions with an informative message:

func testNewAPI() {
    guard #available(iOS 14, *) else {
      XCTFail("Only available in iOS 14 and above")
      return
    }
    // perform test
}
func testNewAPI() throws {
    guard #available(iOS 14, *) else {
      throw XCTSkip("Only available in iOS 14 and above")
    }
    // perform test
}

XCTFail and XCTSkip

If a certain pre-condition isn’t met, you can opt to fail the test or skip it. For example, if you’re writing a test to verify an API that’s only available in iOS 14 and above, you can fail the test for iOS simulators running older version with an informative message:

func testNewAPI() {
    guard #available(iOS 14, *) else {
      XCTFail("Only availble in iOS 14 and above")
      return
    }

    // perform test
}

Making things @testable

When you import Foundation, Swift brings in the public interface for that module. You might create a Banking module for your banking app that imports the public interface. But you might want to check the internal state with XCTAssert. Instead of making things public that really shouldn’t be, you can do this in your test code:

@testable import Banking

The setUp and tearDown methods

You’ll notice that both test methods start by creating a new checking account, and it’s likely that many of the tests you’d write will do the same. Luckily there’s a setUp method. This method executes before each test, and its purpose is to initialize the needed state for the tests to run.

var checkingAccount: CheckingAccount!

override func setUp() {
  super.setUp()
  checkingAccount = CheckingAccount()
}
override func tearDown() {
  checkingAccount.withdraw(amount: checkingAccount.balance)
  super.tearDown()
}

Challenges

Before moving on, here are some challenges to test your knowledge of access control and code organization. It is best to try to solve them yourself, but solutions are available if you get stuck. These came with the download or are available at the printed book’s source code link listed in the introduction.

Challenge 1: Singleton pattern

A singleton is a design pattern that restricts the instantiation of a class to one object.

Challenge 2: Stack

Declare a generic type Stack. A stack is a LIFO (last-in-first-out) data structure that supports the following operations:

Challenge 3: Character battle

Utilize something called a static factory method to create a game of Wizards vs. Elves vs. Giants.

let elf = GameCharacterFactory.make(ofType: .elf)
let giant = GameCharacterFactory.make(ofType: .giant)
let wizard = GameCharacterFactory.make(ofType: .wizard)

battle(elf, vs: giant) // Giant defeated!
battle(wizard, vs: giant) // Giant defeated!
battle(wizard, vs: elf) // Elf defeated!

Key points

  • Access control modifiers are private, fileprivate, internal, public and open. The internal access level is the default.
  • Modifiers control your code’s visible interface and can hide complexity.
  • private and fileprivate protect code from being accessed by code in other types or files, respectively.
  • public and open allow code access from another module. The open modifier additionally lets you override from other modules.
  • When you apply access modifiers to extensions, all members of the extension receive that access level.
  • Extensions that mark protocol conformance cannot have access modifiers.
  • The keyword available can be used to evolve a library by deprecating APIs.
  • You use unit tests to verify your code works as expected.
  • @testable import lets you test internal API.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2020 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.