프로토콜 지향 프로그래밍

김상우·2022년 8월 20일
2

[스위프트 4: 프로토콜 지향 프로그래밍] 을 읽고 정리한 글입니다 📖


1장 : 프로토콜 시작

  • 다룰 내용
    • 프로토콜을 사용해 프로퍼티와 기능 요구 사항을 정의하는 방법
    • 프로토콜 상속과 컴포지션을 사용하는 방법
    • 프로토콜을 타입으로 사용하는 방법
    • 다형성
    • 프로토콜을 사용해 델리게이션 패턴을 구현하는 방법
    • 프로토콜을 사용해 타입 요구 사항을 설계하는 방법

애플은 2015년 세계 개발자 회의 WWDC 에서 스위프트 2를 소개하면서 세계 최초의 프로토콜 지향 프로그래밍 언어라고 발표했습니다.

프로토콜이 프로토콜 지향 프로그래밍의 전부일 것이라고 생각할 수도 있지만 이는 잘못된 생각입니다. 프로토콜 지향 프로그래밍은 앱을 개발하는 새로운 방법일 뿐만 아니라 앱 설계에 대해 어떻게 생각해야하는지에 관한 새로운 방법이기도 합니다.

.

스위프트에서 프로토콜은 객체지향 언어의 인터페이스와 유사합니다. 프로토콜은 작업을 수행하기 위해 타입에서 필요로 하는 메소드, 프로퍼티, 그리고 다른 요구 사항을 정의하는 계약의 역할을 합니다. 프로토콜을 채택하거나 따르는 타입은 프로토콜에서 정의한 요구 사항을 구현할 것을 약속하기 때문에 프로토콜이 계약의 역할을 한다고 설명합니다.

대부분의 현대적인 객체지향 프로그래밍 언어에는 클래스 계층 구조를 사용해 표준 라이브러리를 구현합니다. 하지만 스위프트 표준 라이브러리는 프로토콜에 기반을 둡니다.

그렇기 때문에 애플은 개발자에게 프로토콜 지향 프로그래밍 패러다임을 사용하도록 권장할 뿐만 아니라, 애플 스스로 스위프트 표준 라이브러리에서 프로토콜을 사용한다는 것을 알 수 있습니다.

.

프로토콜 문법

1. 프로토콜 다중 상속

Java 의 클래스는 다중 상속이 불가능, Java 의 인터페이스는 다중 상속 가능, Swift 의 프로토콜은 다중 상속이 가능합니다.

struct MyStruct: MyProtocol, AnotherProtocol {
	//
}

2. 프로퍼티 요구 사항

프로토콜은 자세한 구현체는 프로토콜을 따르는 타입에 맞기기 때문에, 저장 프로퍼티 (store property) 나 연산 프로퍼티 (computed property) 를 선언하지 않습니다.

프로토콜에서 프로퍼티를 정의할 때는 get 과 set 키워드를 사용해 읽기 전용 프로퍼티인지 읽기 쓰기 프로퍼티인지를 반드시 명시해줘야 합니다. 또한 프로토콜에서는 타입 추론을 사용할 수 없으므로 프로퍼티의 타입 역시 명시해줘야 합니다.

3. 메소드 요구 사항

프로토콜은 프로토콜을 따르는 타입에 구체적인 메소드를 제공할 것을 요구할 수 있습니다. static 키워드를 사용함으로써 인스턴스 메소드나 타입 메소드가 되도록 정의할 수 있습니다. 그리고 메소드 매개변수에 기본 값을 추가하는 것은 허용되지 않습니다.

구조체와 같은 값 타입 (value type) 의 경우 메소드가 메소드 자신이 속해 있는 인스턴스를 변경하고자 의도하는 경우에는 반드시 메소드 정의부 앞부분에 mutating 키워드를 추가해줘야만 합니다. 이 키워드는 메소드가 자신이 속해 있는 인스턴스와 인스턴스에 있는 어떠한 프로퍼티도 변경할 수 있음을 나타냅니다.

mutating func changeName()

참조 타입 (reference type) 의 경우에는 mutating 키워드를 입력할 필요가 없습니다. mutating 키워드는 오직 value 타입 (구조체, 열거형) 에만 사용합니다.

4. 선택적인 요구 사항

프로토콜의 요구사항을 선택적으로 정의하길 바라는 경우가 있습니다. 그러기 위해서는 프로토콜을 정의할 때 @objc 속성이 프로토콜 앞부분에 위치해야 합니다.

@objc 속성을 가진 프로토콜은 오직 클래스만이 채용할 수 있습니다.
optional 키워드를 사용하면 프로퍼티나 메소드가 선택 가능하다는 것으로 표시할 수 있습니다.

@objc protocol Phone {
	var phoneNumber: String {get set}
    @objc optional var emailAddress: String {get set}
    func dialNumber()
    @objc optional func getEmail()
}

프로토콜 컴포지션

프로토콜 컴포지션 (protocol composition) 은 타입이 여러 프로토콜을 채용할 수 있게 해줍니다. 프토로콜 컴포지션은 모든 요구 사항을 단일 프로토콜이나 단일 클래스에 상속하지 않고 요구 사항을 여러 작은 컴포넌트로 나눌 수 있게 해줍니다.

프토코롤 컴포지션은 타입군의 높이를 증가시키기보다는 너비를 증가시키게 해주는데, 이는 해당 프로토콜을 따르는 타입 모두가 필요로 하는 요구 사항이 아닌 것을 포함하는 비대한 타입을 생성하는 것을 피하게 해준다는 의미입니다.

예를들어, 이 클래스 계층 구조는 타입군의 높이가 높고, 좋은 구조가 아닙니다. Amateur 와 Pro 클래스 모두는 하위 클래스로 별도의 축구선수 클래스를 갖기 때문입니다. 여기서 클래스 간에 수많은 중복 코드를 갖게 할 것입니다.

프로토콜 컴포지션을 사용한다면, Amatuer / Pro / FootballPlayer / BaseballPlayer 로 나눈 뒤 Amateur 과 Football 을 함께 채택하는 깔끔한 코드를 작성할 수 있습니다.
그렇다해도, 프로토콜을 너무 세밀하게 나누는 것도 좋지 않습니다.

프로토콜을 타입으로 사용

프로토콜에 아무런 기능이 구현돼 있지 않다고 해도 스위프트에서는 하나의 완벽한 타입으로 간주합니다. 이는 프로토콜을 함수의 매개변수나 반환 타입으로 사용할 수 있다는 의미입니다.

또한 프로토콜을 변수나 상수, 그리고 컬렉션에 대한 타입으로도 사용할 수 있습니다.

프로토콜과 다형성

프로그래밍 언어에서 다형성은 여러 타입을 위한 단일 인터페이스입니다. 다형성을 배워야 하는 두 가지 이유는 다음과 같습니다.

  1. 일상적인 대화에서 이런 멋진 단어를 사용하면 매우 지적으로 보이기 때문입니다.
  2. 다형성은 가장 유용한 프로그래밍 기술 중 하나를 객체지향 프로그래밍뿐만 아니라 프로토콜지향 프로그래밍에도 제공하기 때문입니다.
protocol Person {
    var name: String {get set}
    var age: Int {get set}
}

var personArray = [Person]()

struct SwiftProgrammer: Person {
    var name: String
    var age: Int
    var techStack: String
}

struct Progamer: Person {
    var name: String
    var age: Int
    var team: String
}

var ksw = SwiftProgrammer(name: "kimsangwoo", age: 27, techStack: "iOS")
var faker = Progamer(name: "faker", age: 27, team: "SKT")

// 다형성
personArray.append(ksw)
personArray.append(faker)

for person in personArray {
	// ** Person 프로토콜에는 team 이 없기 때문에 team 은 접근 불가능
	person.team
}

프로토콜과 형 변환

위 예제에서 person.team 을 접근 가능하게 하려면 형 변환 (type casting) 을 해줘야 합니다. 스위프트에서는 특정 타입의 인스턴스인지를 확인하기 위해 is 키워드를 사용하고, 특정 타입으로 다루기 위해 as 키워드를 사용합니다.

연관 타입과 프로토콜

연관 타입 associatedtype 은 프로토콜 내에서 타입을 대신해 사용할 수 있는 플레이스홀더명을 제공합니다. 연관 타입에서 사용하는 실제 타입은 프로토콜이 채택 되기 전까지는 정의되지 않습니다.

protocol Queue {
	 associatedtype QueueType
    mutating func addItem(item: QueueType)
    mutating func getItem() -> QueueType?
}

struct IntQueue: Queue {
	var items = [Int]()
    mutating func addItem(item: Int) {
    	items.append(item)
    }
    mutating func getItem() -> Int? {
    	if items.count >0 {
        	return items.remove(at: 0)
        }
        else {
        	return nil
        }
    }
}
       

델리게이션 **

델리게이션 패턴은 매우 간단하면서도 강력한 패턴으로, 어느 한 타입의 인스턴스가 다른 인스턴스를 대신해서 동작하는 상황에 잘 맞습니다. 동작은 위임하는 (delegating) 인스턴스는 델리게이트 인스턴스의 참조를 저장하고 있다가, 어떠한 동작이 발생하면 델리게이팅 인스턴스는 계획된 함수를 수행하기 위해 델리게이트를 호출합니다. 말이 어려운데 코드를 보면 이해가 쉽습니다.

// 날 수 있는 새.
protocol Bird {
    func fly()
}
// 날아다니는 걸 보여주는 서커스.
class Circus {
    // 누구든 좋으니 날 수 있는 놈 아무나 날아봐. 해줘 ~
    // 누군가 날아주길 기대하며 떠넘긴다. (delegating)
    var delegate: Bird?
    
    func showFlying() {
        // 독수리인지 비둘기인지는 중요치 않아. 걍 아무나 날기만 해줘.
        delegate?.fly()
    }
}
// 독수리
class Eagle: Bird {
    init(circus: Circus) {
        // 내가 날아줄게 (delegated)
        circus.delegate = self
    }
    
    func fly() {
        print("쌔애앵")
    }
}

// 비둘기
class Pigeon: Bird {
    init(circus: Circus) {
        // 내가 날아줄게 (delegated)
        circus.delegate = self
    }
    
    func fly() {
        print("구구구")
    }
}

var someCircus = Circus()
var somePigeon = Pigeon(circus: someCircus)

// 만약 참새라는 클래스가 새로 생겨서 들어와도 좋아. 코드 수정이 필요가 없으니까.
someCircus.showFlying()

쉽게 말하면, 프로토콜에 정의된 내용을 누군가가 대신해주길 바라며 떠넘기고 (delegating instance), 그 위임받은자 (delegated instance) 는 그 일을 구체적으로 대신해줍니다.

델리게이션 패턴을 사용함으로써 느슨한 결합 (loose coupling) 을 가지게합니다. 느슨한 결합은 책임의 분리를 촉진합니다. 이는 요구 사항이 변경되는 경우 작업을 매우 쉽게 바꿀 수 있게 해줍니다.

프로토콜을 사용해 설계하기

객체지향 프로그래밍 세계에서는 서브클래스를 위한 모든 기본적인 요구 사항을 포함하는 슈퍼클래스를 갖습니다. 프로토콜 설계 방식은 이와는 좀 다릅니다.

프로토콜 지향 프로그래밍 세계에서는 슈퍼클래스 대신 프로토콜을 사용하며, 이는 요구 사항을 더 큰 덩어리의 프로토콜이 아닌 작고 구체적인 프로토콜로 나누기에 매우 적절합니다.

다음은 Robot 을 프로토콜을 통해 설계하는 과정 예시입니다.

// 2차원상에서 움직이는 로봇의 움직임 설계
protocol RobotMovement {
    func forward(speedPercent: Double)
    func reverse(speedPercent: Double)
    func left(speedPercent: Double)
    func right(speedPercent: Double)
    func stop()
}

// 하늘로 가는 3차원상에서 움직이는 로봇이 필요해졌다면 ?
// 2차원상에서 움직이는 로봇을 상속해서 추가적인 요구 사항을 기입합니다.
// 이렇게 하면 다형성을 사용할 수 있게 됩니다.
// 로봇이 하늘로 갈 수 있는지 판단하기 위해 is RobotMovementThreeDimensions 를 사용합니다.
protocol RobotMovementThreeDimensions: RobotMovement {
    func up(speedPercent: Double)
    func down(speedPercent: Double)
}

// 로봇에 부착할 센서
protocol Sensor {
    var sensorType: String {get}
    var sensorName: String {get set}
    
    init(sensorName: String)
    func pollSensor()
}

protocol EnvironmentSensor: Sensor {
    func currentTemperature() -> Double
    func currentHumidity() -> Double
}

protocol RangeSensor: Sensor {
    func getCurrentRange() -> Double
}

// RobotMovement 와 Sensor 를 가지는 Robot
protocol Robot {
    var name: String {get set}
    var robotMovement: RobotMovement {get set}
    var sensors: [Sensor] {get}
    
    init(name: String, robotMovement: RobotMovement)
    func addSensor(sensor: Sensor)
    func pollSensors()
}

스위프트 표준 라이브러리의 프로토콜

애플은 스위프트 표준 라이브러리에서 프로토콜을 광범위하게 사용합니다.
-> http://swiftdoc.org

Dictionary 를 살펴보면 Collection, Sequence 등 다양한 프로토콜을 채택하고 있음을 알 수 있습니다.


2장 : 타입 선택

애플은 스위프트 개발자에게 참조 타입보다 값 타입을 선호하라고 이야기할 뿐만 아니라 이런 철학을 몸소 실천합니다. 스위프트 표준 라이브러리를 살펴보면 구조체를 사용해서 타입 대부분을 구현했다는 것을 알 수 있습니다. (http://swiftdoc.org)

대부분의 전통적인 객체지향 프로그래밍 언어들은 참조 타입인 클래스를 생성합니다. 스위프트는 다른 객체지향 언어와는 달리 구조체에 클래스와 동일한 기능들이 매우 많습니다. 그러면서 스위프트의 구조체는 값 타입입니다.

애플은 참조 타입 보다 값 타입을 선호할 것을 권고하고 있습니다.

  • 다룰 내용
    • 구조체란 무엇이며 어떻게 사용하는가
    • 열거형이란 무엇이며 어떻게 사용하는가
    • 튜플이란 무엇이며 어떻게 사용하는가
    • 값 타입과 참조 타입의 차이점은 무엇인가
  • 참조 타입 인스턴스를 전달한다는 것은 원본 인스턴스의 참조를 전달한다는 의미이며, 이는 두 참조가 같은 인스턴스를 공유한다 (원본을 공유한다) 는 뜻입니다.
  • 값 타입 인스턴스를 전달한다는 것은 원본 인스턴스의 복사본을 전달한다는 의미이며, 이는 각각의 인스턴스가 유일한 사본이라는 의미이기도 합니다. 구조체, 열거형, 튜플 등이 값 타입에 속합니다.

구조체

구조체는 단언컨데 스위프트 언어에서 가장 중요한 타입이라고 책의 저자는 말합니다. 애플이 구조체를 사용해 스위프트 표준 라이브러리 대부분을 구현 할 수 있었던 이유는, 스위프트의 구조체가 클래스와 같은 기능이 많기 때문입니다.

struct MyStruct {
	var oneProperty: String
    
    func oneFunction() {
    }
 }
  • 클래스와 구조체의 비교
    • 구조체는 작성자가 생성자를 제공하지 않을 경우 초기화돼야하는 모든 프로퍼티를 설정하기 위한 기본 생성자를 만들어줍니다.
    • 구조체는 일부 메소드에서 mutating 키워드를 사용합니다. 구조체는 값 타입이기 때문에 기본적으로 인스턴스 메소드 내부에서는 구조체 프로퍼티 값을 변경할 수 없습니다. 구조체 메소드 내에서 구조체 프로퍼티의 값을 변경하고자 할때는 반드시 mutating 키워드를 사용해야 합니다.

열거형

스위프트의 열거형은 다른 일반적인 언어의 열거형보다 강력합니다.

  • 스위프트 열거형의 특징
  1. 원시 값 (raw values)
  2. 연관 값 (associated values)
  3. 생성자, 메소드, 연산 프로퍼티
// 1. 원시 값 (raw values)
enum Devices: String {
    case IPod = "iPod"
    case IPhone = "iPhone"
    case Ipad = "iPad"
}

Devices.IPhone.rawValue // "iPhone"

// -------------------------------

// 2. 연관 값 (associated values)
enum Devices {
    case IPod(model: String, memory: Int)
    case IPhone(model: String, memory: Int)
    case Ipad(model: String, memory: Int)
    
    // 3. 연산 프로퍼티
    var modelName: String {
        switch self {
        case .IPod(let model, _):
            return model
        case .IPhone(let model, _):
            return model
        case .Ipad(let model, _):
            return model
        }
    }
}

let myPhone = Devices.IPhone(model: "13Pro", memory: 128)
let myPhoneModelName = myPhone.modelName

튜플

스위프트에서 튜플은 유한하며 쉼표로 구분하는, 순서있는 요소의 목록입니다.

// 이름 없는 튜플
let mathGrade1 = ("Kim", 100)
let (name, score) = mathGrade1
print(name)
print(score)

// 이름을 부여한 튜플
let mathGrade2 = (name: "Park", grade: 100)
print(mathGrade2.name)
print(mathGrade2.grade)

// 별칭 부여
typealias myTuple = (mathScore: Int, scienceScore: Int)

프로토콜

프로코롤은 실제로 인스턴스를 생성할 수 없기 때문에 프로코롤은 타입으로 간주할 수 없다고 생각하는 사람들이 많습니다. 하지만 스위프트에서 프로토콜은 타입으로 사용할 수 있습니다.

다만 프로토콜은 값 타입도, 참조 타입도 아닙니다.

재귀적 데이터 타입

재귀적 데이터 타입은 같은 타입의 다른 값을 프로퍼티로 갖는 타입을 말합니다. 리스트나 트리 같은 동적 자료 구조를 정의할 때 재귀적 데이터 타입을 사용합니다. 이런 자료 구조들은 런타임에 동적으로 데이터 크기가 늘어나거나 줄어들 수 있습니다.

재귀적 데이터 타입은 참조 타입에서만 사용 가능한데, 그 이유를 다음 링크드 리스트의 예시를 통해 이해할 수 있습니다.

// 에러가 나지 않는 클래스 코드
class LinkedListWithClass {
    var value: String
    var next: LinkedListWithClass?
    init(value: String) {
        self.value = value
    }
}

struct LinkedListWithStruct {
    var value: String
    // 선언할 때 에러가 나는 코드
    var next: LinkedListWithStruct?	// error !
}

// 에러가 안났다 치고 구조체로 링크드 리스트 구현
var one = LinkedListWithStruct(value: "One", next: nil)
var two = LinkedListWithStruct(value: "Two", next: nil)
var three = LinkedListWithStruct(value: "Three", next: nil)

one.next = two
two.next = three    // 이 코드는 의도대로 작동하지 않음

위 코드의 문제점은, 값 타입은 전달하는 순간 원본이 아닌 복사본을 전달하기 때문입니다. 제대로 된 링크드 리스트의 역할을 수행할 수 없습니다.


3장 : 익스텐션

스위프트의 프로토콜에서는 extension (= 확장) 을 사용할 있습니다. extension 은 이미 존재하는 타입의 소스코드가 없더라도 기능을 추가할 수 있게 해줍니다. 프로토콜은 기능을 가질 수 없는데, 어떻게 프로토콜에 기능을 추가한다는 것인지 궁금해 하는 사람들이 많을 것입니다.

extension 을 통해서는 다음과 같은 아이템들을 추가할 수 있습니다.

  1. 연산 프로퍼티 (computed porperties)
  2. 인스턴스 메소드와 타입 메소드
  3. convenience init
  4. Subscript
  5. 저장 프로퍼티는 추가할 수 없습니다.

익스텐션이 유용한 이유

익스텐션이 유용한 이유를 이해하기 위해서는 익스텐션을 통해 해결하고자 했던 문제부터 이해해야합니다.

대부분의 객체지향 프로그래밍 언어에서 이미 존재하는 클래스에 기능을 추가하고자 하는 경우에는 클래스를 서브클래싱하게 됩니다. 그런 다음 새로운 서브클래스에 새로운 기능을 추가합니다.

이 방법의 문제점은 원본 클래스에 실제로 기능을 추가하는것은 아니라는 것입니다. 그러므로, 추가적인 기능이 필요한 원본 클래스의 모든 인스턴스를 새로운 서브클래스의 인스턴스로 변경해야만 합니다.

이 밖에 마주하게 되는 또 다른 문제점은 참조 타입(클래스)만 서브 클래싱이가능하다는 점입니다. 이는 구조체나 열거형과 같은 값 타입은 서브클래싱을 할 수 없다는 의미이고, 더 큰 문제는 스위프트 표준 라이브러리 대부분이 구조체로 이뤄졌다는 사실입니다.

익스텐션을 사용하면 확장하고자하는 타입에 새로운 기능을 직접 추가할 수 있습니다. 이는 해당 타입의 모든 인스턴스를 수정할 필요 없이 자동으로 새로운 기능을 받게 된다는 것을 의미합니다. 또한 참조 타입 뿐만 아니라 값 타입의 확장을 가능하게 합니다.

그래서 정리하자면,

1. 새로운 서브클래스를 정의하지 않아도 기존 인스턴스들에 기능을 추가할 수 있게됩니다.
2. 참조 타입 뿐만 아니라, 값 타입에서 기능 확장을 가능하게 합니다.

프로토콜 익스텐션

프로토콜에서는 구체적인 기능을 명시할 수 없습니다. 하지만 프로토콜에 익스텐션을 활용하면 구체적인 기능을 추가할 수 있게됩니다.

protocol SomeProtocol { }

extension SomeProtocol {
    func sayHello() {
        print("Hello")
    }
}

struct SomeStruct: SomeProtocol {
    func structFunction() {
        sayHello()
    }
}

let someStruct = SomeStruct()
someStruct.structFunction()		// "Hello"

extension ~ where 구문

extension ~ where 구문을 사용하면 구체적으로 어떤 프로토콜을 따르는 곳에서 기능을 확장할 것인지 구체화할 수 있게 됩니다.

// 롤챔피언 프로토콜
protocol 롤챔피언 {
    var name: String { get }
    var skillSet: String { get }
}

// 롤챔피언 중에서 탱커 프로토콜
protocol 탱커 where Self: 롤챔피언 {
    var initiatingSkill: String { get }
}

struct 말파이트: 롤챔피언, 탱커 {
    var name: String = "말파이트"
    var skillSet: String = "QWER"
    var initiatingSkill: String = "R"
}

Equatable 따르기

Equatable 프로토콜을 따른다면 동등 비교를 위해 항등 연산자 (==) 와 비항등 연산자 (!=) 를 사용할 수 있게 됩니다.

struct Place {
    let id: String
    let latitude: Double
    let longitude: Double
}

extension Place: Equatable {
    static func ==(lhs: Place, rhs: Place) -> Bool {
        return lhs.id == rhs.id &&
        lhs.latitude == rhs.latitude &&
        lhs.longitude == rhs.longitude
    }
}

var placeOne = Place(id: "Magok", latitude: 123.00, longitude: 45.00)
var placeTwo = Place(id: "Balsan", latitude: 124.00, longitude: 46.00)

print(placeOne == placeTwo)

4장 : 제네릭

애플은 제네릭이 스위프트의 가장 강력한 기능 중 하나이며, 다양한 스위프트 표준 라이브러리들이 제니릭을 사용해 만들어졌다고 이야기합니다. 프로토콜과 제네릭을 결합하면 더욱 더 강력해집니다.

제네릭은 중복을 피하면서 매우 유연하고 재사용이 가능한 코드를 작성할 수 있게 해줍니다. 스위프트와 같은 타입 안정성을 가진 언어에서는 종종 여러 타입에 유용한 함수나 타입을 작성해야 하는 경우가 발생합니다.

예를 들어, 두 변수의 값을 교환하는 함수를 작성하는데, 이 함수가 두 개의 String 타입 값을 서로 교환할 뿐만 아니라 Integer 타입과 Double 타입도 서로 교환할 수 있게 하고자 합니다.

제네릭이 없다면 세 개의 독립된 함수를 작성해야만 할 것입니다.. 제네릭을 사용하면 서로 다른 타입에 대해 교환 기능을 제공하는 하나의 제네릭 함수를 작성할 수 있게 됩니다.

제네릭은 함수나 타입이 다음과 같이 이야기할 수 있게 해줍니다.

"스위프트가 타입 안정성을 가진 언어라는 것은 알지만, 아직은 필요한 타입을 알지 못합니다. 따라서 지금은 플레이스홀더를 제공하고 실행해야 하는 타입에 대해서는 '런타임'에 알려주겠습니다."

실제로 스위프트의 배열은 제네릭으로 구현되어있고, 옵셔널(?)도 제네릭으로 구현되어있습니다. 옵셔널 타입은 두 개의 사용 가능한 값인 None과 Some(T)로 이뤄진 열거형으로 정의돼 있으며, 여기서 T는 적절한 타입의 연관 타입을 나타냅니다. 옵셔널에 nil 을 대입하면 None 값을 갖게 되고, 옵셔널에 어떠한 값을 대입하면 적절한 타입의 연관 값을 갖는 Some 값을 갖게 됩니다.

다음은 옵셔널의 내부 정의입니다. 제네릭을 활용해 정의한 것을 알 수 있습니다.

enum Optional<T> {
	case None
    case Some(T)
}

제네릭 함수

import Foundation

// 제네릭을 사용하지 않는다면, 여러 타입에 대한 같은 함수를 여러번 정의해야 함.
func swapInts(a: inout Int, b: inout Int) {
    let tmp = a
    a = b
    b = tmp
}

func swapStrings(a: inout String, b: inout String) {
    let tmp = a
    a = b
    b = tmp
}

var sampleA = 10
var sampleB = 20

swapInts(a: &sampleA, b: &sampleB)

sampleA // 20
sampleB // 10

// 제네릭을 사용해서 중복을 줄인 멋진 코드 작성.
// T 는 스위프트에게 타입을 런타임 단계에서 정의할 것이라고 알려줌.
func swapGeneric<T>(a: inout T, b: inout T) {
    let tmp = a
    a = b
    b = tmp
}

swapGeneric(a: &sampleA, b: &sampleB)

sampleA // 10
sampleB // 20

제네릭 타입 제약

제네릭 타입이 어떠한 제약을 만족하는 것들이 들어올지 제한 할 수 있습니다.

// comparable 를 만족하는 타입만 받음으로써 a == b 를 물을 수 있게 됨.
func isEqual<T: Comparable>(a: T, b: T) -> Bool {
    return a == b 
}

제네릭 타입

스위프트의 배열과 옵셔널이 어떠한 타입과도 함께 동작할 수 있는 것처럼, 제네릭 타입은 어떠한 타입과도 동작이 가능한 클래스나 구조체 또는 열거형을 의미합니다.

// 제네릭 타입 예시
struct List<T> {
    var items = [T]()
    
    mutating func add(item: T) {
        items.append(item)
    }
    
    func getItemAtIndex(index: Int) -> T? {
        if items.count > index {
            return items[index]
        }
        else {
            return nil
        }
    }
}

연관 타입

연관 타입은 프로토콜 내에서 타입 대신에 사용될 수 있는 플레이스홀더명을 정의합니다. 실제로 사용되는 타입은 프로토콜에 채택되기 전까지는 명시되지 않습니다. 연관타입은 associatedtype 키워드를 사용해서 명시합니다.

// 프로토콜에서 연관타입 사용
protocol MyProtocol {
    associatedtype E
    var items: [E] { get set }
    mutating func add(item: E)
}

struct MyIntType: MyProtocol {
    var items: [Int] = []
    mutating func add(item: Int) {
        items.append(item)
    }
}

// 연관타입과 제네릭을 함께 사용
struct MyGenericTyep<T>: MyProtocol {
    var items: [T] = []
    mutating func add(item: T) {
        items.append(item)
    }
}

Copy-on-write (COW)

일반적으로 구조체와 같은 값 타입의 인스턴스를 전달하는 경우에는 인스턴스의 복사본을 새롭게 생성하게 됩니다. 이 말은 50,000 개의 요소를 가진 커다란 자료 구조로 돼 있다면 해당 인스턴스를 전달할때마다 50,000 요소를 모두 복사해야만 한다는 의미입니다.

이는 앱의 성능에 심각한 영향을 미치게 됩니다. 애플은 이러한 문제를 해결하기 위해 스위프트 표준 라이브러리에 있는 모든 자료구조에 COW 기능을 구현했습니다. COW 를 사용하면, 스위프트는 자료구조에 변화가 생기지 않는 이상 두 번째 복사본을 만들지 않습니다.

이는 매우 훌륭한 기능이지만, 개발자가 만든 커스텀 값 타입은 이러한 기능을 자동으로 갖지는 못합니다.


5장 : 객체지향 프로그래밍

스위프트를 프로토콜 지향 언어로 어떻게 사용할 수 있을지 살펴보기 전에 스위프트를 객체지향 언어로 어떻게 사용할 수 있는지를 먼저 논의해보는 것이 좋습니다. 객체지향 프로그래밍을 잘 이해하고 있으면 프로토콜 지향 프로그래밍을 이해하는데 도움이 됩니다.

5장에서는 객체지향 방식으로 게임에서 Vehicle 에 대한 타입을 어떻게 정의할 수 있는지 살펴보고,
6장에서는 동일한 클래스를 프로토콜 지향 방식으로 어떻게 설계할 수 있는지를 살펴봅니다.

객체지향 프로그래밍은 일종의 설계 철학입니다. 객체지향 프로그래밍 언어를 사용해 애플리케이션을 개발한다는 것은 C나 파스칼 같이 오래된 절차적 언어로 개발하는 것과는 근본적으로 다릅니다.

객체는 프로퍼티로 불리는 객체의 속성정보와 메소드로 불리는 객체가 수행하는 행위의 정보를 가진 자료구조 입니다. 클래스는 일종의 구성체로, 프로퍼티와 행위를 코드에서 나타내고자 하는 개체를 모델링하는 단일 타입으로 캡슐화 합니다.

샘플 코드를 위한 요구 사항

다음은 만들어야 할 이동 수단 (Vehicle) 타입을 위한 요구 사항 목록입니다.

  • 이동수단은 해상, 지상, 공중 이렇게 세 개의 범주를 갖습니다.
  • 이동수단은 자신이 속해 있는 범주와 일치하는 타일에 있으면 이동 또는 공격 할 수 있습니다.
  • 이동수단은 자신이 속해 있는 범주와 일치하는 타일에 있지 않으면 이동 또는 공격 할 수 없습니다.
  • 이동수단의 체력이 0이 되면 이동수단은 움직이지 못하는 상태가 됩니다.
  • 순회가 가능한 단일 배열에 활동 가능한 모든 이동 수단을 갖고 있습니다.

객체지향 프로그래밍 언어로서의 스위프트

위 사진은 객체지향 설계 방식의 클래스 계층 구조입니다.
스위프트는 단일 상속 언어이기 때문에 클래스 자신이 상속하는 슈퍼 클래스는 오직 한 개 뿐입니다. 이 다이어그램에서, 이름이 Vehicle 인 슈퍼클래스 한 개와 이름이 Tank, Amphibious, Submarine, jet, Transformer 인 다섯 개의 서브클래스가 있습니다.

요구 사항의 세 가지 범주 (지상, 해상, 공중)을 위한 독립된 슈퍼클래스를 갖는 중간 계층이 존재하는 더 큰 클래스 계층 구조를 만들고 싶다고 생각할 수도 있습니다. 이러한 방식은 각 범주에 대한 코드를 자체적인 슈퍼클래스로 분리해주지만, 이렇게 되면 요구 사항에 부합하지 않게 됩니다. 이것이 불가능한 이유는 이동수단 타입은 여러 범주 (지상, 해상, 공중)의 멤버가 될수 있으며, 스위프트와 같은 단일 상속 언어에서 각 클래스는 오직 하나의 슈퍼클래스만 가질 수 있기 때문입니다. 즉, 예를 들어 지상과 해상 슈퍼클래스를 따로 만들면 Amphibious 클래스는 지상 또는 해상 타입 중 한 가지의 서브 클래스는 될 수 있지만, 두 타입 모두의 서브 클래스는 될 수 없다는 것을 의미합니다.

스위프트는 단일 상속 언어이고, 모든 이동수단 클래스에 대해 오직 한 개의 슈퍼클래스만 가질 수 있기 때문에 슈퍼클래스는 세 범주 각각에서 필요한 코드를 모두 포함해야만 하게 됩니다.

이처럼 단일 슈퍼클래스를 갖는 것은 객체지향 설계 방식의 문제점 중 하나인데, 이렇게 되면 슈퍼클래스가 매우 비대해지기 때문입니다.

객체지향 설계 방식의 Vehicle

다음은 서로 다른 이동수단 타입을 정의하는 데 사용될 열거형입니다.

enum TerrainType {
    case land   // 지상
    case sea    // 해양
    case air    // 공중
}

그리고 다음은 객체지향 설계 방식으로 짠 Vehicle 클래스 입니다.

class Vehicle {
    fileprivate var vehicleTypes = [TerrainType]()
    fileprivate var vehicleAttackTypes = [TerrainType]()
    fileprivate var vehicleMovementTypes = [TerrainType]()
    fileprivate var landAttackRange = -1
    fileprivate var seaAttackRange = -1
    fileprivate var airAttackRange = -1
    fileprivate var hitPoints = 0   // 체력
    
    func isVehicleType(type: TerrainType) -> Bool {
        return vehicleTypes.contains(type)
    }
    
    func canVehicleAttack(type: TerrainType) -> Bool {
        return vehicleAttackTypes.contains(type)
    }
    
    func canVehicleMove(type: TerrainType) -> Bool {
        return vehicleMovementTypes.contains(type)
    }
    
    func doLandAttack() {}
    func doLandMovement() {}
    
    func doSeaAttack() {}
    func doSeaMovement() {}
    
    func doAirAttack() {}
    func doAirMovement() {}
    
    func takeHit(amount: Int) { hitPoints -= amount }
    func hitPointsRemaining() -> Int { return hitPoints }
    func isAlive() -> Bool { return hitPoints > 0 ? true : false }
}

Vehicle 클래스 하나에 land, sea, air 에 대한 모든 코드를 작성해야 합니다. 이렇게 거대해진 클래스는 스위프트와 같은 단일 상속 객체지향 언어에서 큰 문제로 작용합니다.

이렇게 거대한 슈퍼 클래스에서는 실수하기 쉬울 뿐만 아니라, 불필요한 타입에 기능을 제공하기 쉽습니다.

예를 들어 잠수함은 명백히 공중을 공격할 수 없음에도 불구하고 Submarine 타입에 공중을 공격하는 능력을 제공하는 airAttackRange 프로퍼티를 매우 쉽게 설정할 수 있습니다.

이 문제는 스위프트가 단일 상속 언어이기 때문에 나타나는 결과입니다.


6장 프로토콜지향 프로그래밍

애플은 2015년 세계 개발자 회의 WWDC 에서 스위프트 2를 소개하면서 스위프트는 세계 최초의 프로토콜 지향 프로그래밍 언어라고 발표했습니다.

이름에서 유추할 수 있듯이 프로토콜이 프로토콜지향 프로그래밍의 전부일 것이라고 생각할 수 있지만, 이는 잘못된 생각입니다.

프로토콜지향 프로그래밍은 실제로 애플리케이션을 작성하는 새로운 방식일 뿐만 아니라 프로그래밍을 생각하는 새로운 방식이기도 합니다.

다룰 내용

  • 프로토콜지향 프로그래밍
  • 프로토콜 컴포지션을 사용하는 방법
  • 프로토콜 상속을 사용하는 방법
  • 프로토콜지향 프로그래밍과 객체지향 프로그래밍 비교

5장에서는 객체지향 방식으로 Vehicle 을 어떻게 설계할 수 있는지 살펴봤습니다.
6장에서는 프로토콜지향 방식으로 동일한 Vehicle 타입을 설계하고 비교하며 공부합니다.

위 사진은 프로토콜지향 방식으로 설계한 다이어그램입니다.

객체지향설계 방식에서는 슈퍼클래스로 설계를 시작했었습니다. 슈퍼클래스는 설계의 중심이며, 모든 서브클래스는 해당 슈퍼클래스로부터 기능과 프로퍼티를 상속받습니다.

프로토콜지향 방식에서는 프로토콜로 설계를 시작합니다. 이 새로운 설계 방식에서는 세 가지 기술을 사용합니다.

  1. 프로토콜 상속
  2. 프로토콜 컴포지션
  3. 프로토콜 확장

프로토콜 상속은 어떠한 프로토콜이 다른 프로토콜로부터 요구 사항을 상속받는 것을 말합니다. 이는 객체지향 프로그래밍의 클래스 상속과 유사합니다. 하지만 슈퍼클래스로부터 기능을 상속받는 대신 여기서는 프로토콜로부터 요구 사항을 상속받습니다. 스위프트에서 프로토콜 상속이 클래스 상속보다 좋은 점 중 하나는, 프로토콜은 여러 프로토콜로부터 요구 사항을 상속받을 수 있다는 점입니다.

프로토콜 컴포지션은 하나 이상의 프로토콜을 따를 수 있게 해줍니다. 프로토콜 상속과 프로토콜 컴포지션은 더 작으면서도 구체적인 프로토콜을 만들 수 있게 해주기 때문에 프로토콜지향 설계방식에서 매우 중요한 개념입니다. 이러한 개념을 이용하면 객체지향 설계 방식에서 봤던 비대해진 슈퍼클래스를 막을 수 있습니다.

프로토콜 확장은 해당 프로토콜을 따르는 타입에 메소드와 프로퍼티 구현체를 제공하게 프로토콜을 확장할 수 있는 기능을 제공합니다.

프로토콜지향 설계 방식의 Vehicle

protocol Vehicle {
    // 남은 체력
    var hitPoints: Int {get set}
}

extension Vehicle {
    mutating func takeHit(amount: Int) {
        hitPoints -= amount
    }
    
    func hitPointsRemaining() -> Int {
        return hitPoints
    }
    
    func isAlive() -> Bool {
        return hitPoints > 0 ? true : false
    }
}

protocol LandVehicle: Vehicle {
    var landAttack: Bool {get}
    var landMovement: Bool {get}
    var landAttackRange: Int {get}
    
    func doLandAttack()
    func doLandMovement()
}

protocol SeaVehicle: Vehicle {
    var seaAttack: Bool {get}
    var seaMovement: Bool {get}
    var seaAttackRange: Int {get}
    
    func doSeaAttack()
    func doSeaMovement()
}

protocol AirVehicle: Vehicle {
    var airAttack: Bool {get}
    var airMovement: Bool {get}
    var airAttackRange: Int {get}
    
    func doAirAttack()
    func doAirMovement()
}

Land, Sea, Air Vehicle 들은 모두 Vehicle 프로토콜로부터 요구 사항을 상속받습니다. 그리고 객체지향 설계 방식에서와 달리, 각 프로토콜들은 각 이동수단 타입에 필요한 요구사항만 갖고 있게 됩니다.

객체지향 설계 방식의 Vehicle 슈퍼클래스는 모든 이동수단 타입에 대한 요구 사항을 갖고 있었습니다. 요구 사항을 세 개의 개별 프로토콜로 나누면 코드를 훨씬 더 안전하고 쉽게 유지하고 관리할 수 있습니다. 공통 기능이 필요한 경우에는 프로토콜 확장을 특정 프로토콜이나 모든 프로토콜에 추가할 수 있습니다.

그리고 위 코드에서는 프로토콜의 프로퍼티를 get 속성으로만 정의했는데, 이는 이 프로토콜을 따르는 타입에서 해당 프로퍼티를 상수로 정의하겠다는 의미입니다. 이러한 방식은 프로토콜지향 설계 방식을 얻게 되는 큰 장점인데, 외부 코드에서 프로퍼티에 설정된 값을 변경하면 추적하기 어려운 에러를 일으킬 수 있고, 이 방식이 그러한 경우를 막을 수 있기 때문입니다.

프로토콜을 따르는 타입 vs 슈퍼클래스를 따르는 타입

다음은 각각 프로토콜을 따른 struct Tank 와 슈퍼클래스를 따른 class Tank 의 구현 코드입니다.

// 프로토콜지향
struct Tank: LandVehicle {
    var hitPoints: Int = 68
    var landAttackRange: Int = 5
    let landAttack: Bool = true
    var landMovement: Bool = true
    
    func doLandAttack() { print("Tank Attack") }
    
    func doLandMovement() { print("Tank Move") }
}

// -----------------------------------------------------

// 객체지향
class Tank: Vehicle {
    override init() {
        super.init()
        vehicleTypes = [.land]
        vehicleAttackTypes = [.land]
        vehicleMovementTypes = [.land]
        landAttackRanges = 5
        hitPoints = 68
    }
    
    override func doLandAttack() { print("Tank Attack") }
    override func doLandMovement() { print("Tank Move") }
}

3가지 차이점을 살펴볼 수 있습니다.

  1. class Tank 는 참조 타입, struct Tank 는 값 타입입니다. 프로토콜지향 설계방식에서는 값 타입을 사용할 것을 추천합니다. 이는 안정성 때문입니다. 언제나 값 타입 인스턴스의 유일한 복사본을 갖게 된다면, 코드의 다른 부분에서 인스턴스를 변경할 수 없다는 것을 신뢰할 수 있기 때문입니다. 이는 데이터를 사용하는 동안 다른 스레드에서 데이터를 변경하는 것을 원치 않는 멀티스레드 환경에서 특히 유용합니다.
  1. struct Tank 는 구조체에서 제공하는 디폴트 이니셜라이저를 사용할 수 있으며, 프로퍼티를 상수로 선언할 수 있습니다. class Tank 는 이니셜라이저를 오버라이드 한 다음 이니셜라이저 내부에서 프로퍼티에 값을 설정 해줘야만 합니다.
  1. struct Tank 는 지상 이동수단을 위한 기능만을 갖고 있습니다. class Tank 는 슈퍼클래스 Vehicle 이 가진 해상 타입과 공중 타입을 위한 기능까지 상속을 받습니다.

지상과 해양 타입을 모두 포함하는 Amphibious 라는 구조체를 만들기 위해서는, 프로토콜 컴포지션을 활용해서 다음과 같이 선언하면 됩니다.

struct Amphibious: LandVehicle, SeaVehicle {
    ...
}
profile
안녕하세요, iOS 와 알고리즘에 대한 글을 씁니다.

0개의 댓글