Object-Oriented Programming: Beyond the Basics

Oct 17 2023 · Swift 5.9, iOS 17, Xcode 15

Lesson 04: Single Responsibility & Open-Closed Principles

Demo 2

Episode complete

Play next episode

Next
Transcript

Open the starter playground. It’s identical to the code you had at the end of lesson three, “Design Patterns”.

In the current implementation of the cards, the flag isCompany is a Boolean variable. Its two possible values indicate whether the contact represents a company or an individual. The value of the flag determines how to handle the relationship between the contacts.

If you introduce different types, you can no longer rely on this flag. Adding more flags will make matters even worse — you’d need to go back to each existing type to accommodate each new flag. Adding new types will gradually become more and more expensive.

Clearly, you need a different approach. Instead of a Boolean flag, you’ll configure each card type to express which card types it can include in its related contacts. When you call addRelatedContact(:), it’ll check if either contact can include the other, then create the connection if the configuration allows it.

Create a new Swift file under Sources. Name it ContactOptions.swift. Then, add this new structure inside it:

public struct ContactOptions: OptionSet {
  public init(rawValue: Int) {
    self.rawValue = rawValue
  }
  public let rawValue: Int
}

OptionSet is a protocol that offers a unique kind of configuration. It’s a set of values, and just like with any set, you can check whether it contains a certain value or not. By specifying that rawValue is of type Int, you can store the different set values in a single integer using its bits.

Add those static properties under ContactOptions:

static let unknown = ContactOptions(rawValue: 1 << 0) // binary = 0001
static let person = ContactOptions(rawValue: 1 << 1) // binary = 0010
static let company = ContactOptions(rawValue: 1 << 2) // binary = 0100
static let emergency = ContactOptions(rawValue: 1 << 3)// binary = 1000
static let publicPayPhone = ContactOptions(rawValue: 1 << 4) // binary = 10000

Next, open ContactCard.swift. Delete the declaration of isCompany and remove all of its references from the child types. Finally, add the following properties in ContactCard:

var canAdd: ContactOptions = []
var cardType: ContactOptions = .unknown

Next, you’ll update addRelatedContact(:). First, change the method signature:

public final func addRelatedContact(_ contact: ContactCard) {

The method is marked as final to prevent overrides because you don’t want to allow the implementation to be changed in subtypes.

Next, open CompanyContactCard.swift and PersonContactCard.swift. Delete the overridden method.

Then, return to ContactCard.swift update addRelatedContact to the following:

public final func addRelatedContact(_ contact: ContactCard) {
  if canAdd.contains(contact.cardType) {
    print("Adding contact to related list.")
    relatedContacts.append(contact.contactID)
  } else {
    print("Can't Add")
  }

The first if condition checks if this contact’s canAdd contains the value of cardType of the other contact.

For instances of CompanyContactCard, this set will contain .person and .company. So if contact.cardType (the other card) has either of those two values, the relationship will happen.

  if contact.canAdd.contains(cardType) {
    print("Adding inverse relation")
    contact.relatedContacts.append(self.contactID)
  } else {
    print("Can't Add Inverse")
  }
  broadcastUpdates()
}

The other if condition handles the inverse, when the other contact’s canAdd set contains the type. When one contact is a Company and the other is a Person, PersonContactCard instances only list .person. So Company cards won’t be added.

Now, you can properly set canAdd and cardType in PersonContactCard and CompanyContactCard. Open PersonContactCard.swift. Update the init to the following:

public override init(firstName: String, lastName: String, phoneNumber: String) {
  super.init(firstName: firstName, lastName: lastName, phoneNumber: phoneNumber)
  canAdd = [.person] // new code
  cardType = .person // new code
}

If you run into errors regarding the ContactOptions, just restart the playground. This should solve the issue. Then, go to CompanyContactCard. Update it to the following:

override init(firstName: String, lastName: String, phoneNumber: String) {
  super.init(firstName: firstName, lastName: lastName, phoneNumber: phoneNumber)
  canAdd = [.person, .company] // new code
  cardType = .company // new code
}

Build and run. The log messages are still the same. The relationship counts haven’t changed.

When you add a new type, such as an EmergencyContactCard, you’ll give the new type its own cardType and canAdd values. Whether you’ll want the existing types to include this new card type in their related contacts or not will be a matter of adding a single value to the allowed list.

This approach treats the modifications more like configuration settings rather than actual code alterations. It gives you greater flexibility to extend the system with minimum changes — or even no changes at all, if there was no change to the existing types’ requirements.

Using OptionSet is one way to create this configuration. You can rely on the class type directly using type(of:), but if the different child types are in different frameworks, it will impact your dependency graph. You might end up having several include statements in your files that aren’t ideal.

Using a new type to define configuration can solve that, but it’ll also mean that for each new contact type you introduce, you’ll need to add a new static value entry.

It’s for you to decide which is the best approach for your system, but the rule is to keep things as isolated as possible to avoid having to make too many modifications when requirements change.

See forum comments
Cinema mode Download course materials from Github
Previous: Instruction 3 Next: Conclusion