23장 - 프로토콜 지향 프로그래밍
스위프트의 표준 라이브러리에서 타입과 관련된 것을 살펴보면 대부분이 구조체로 구현되어 있다. 구조체는 상속이 되지 않지만 프로토콜과 익스텐션, 제네릭 등으로 다양항 공통 기능을 갖도록 구현할 수 있다.
23.1 프로토콜 초기구현
익스텐션은 기존 타입의 기능을 확장하며, 프로토콜은 프로토콜을 채택한 타입이 원하는 기능을 강제로 구현한다. 그런데 특정 프로토콜을 정의하고 여러 타입에서 이 프로토콜을 준수하게 만들어 타입마다 똑같은 메서드, 프로퍼티, 서브스크립트 등을 구현해야 한다면 많은 코드를 중복 사용해야 하며, 유지보수는 힘들어 질 것이다. 이때 필요한 게 바로 익스텐션과 프로토콜의 결합이다.
[코드 20-5]에서 중복 코드를 제거해보자.
/*
코드 23-1. 익스텐션을 통한 프로토콜의 실제 구현
*/
protocol Receiveable {
func received(data: Any, from: Sendable)
}
extension Receiveable {
// 메시지를 수신한다.
func received(data: Any, from: Sendable) {
print("\(self) received \(data) from \(from)")
}
}
// 무언가를 발신할 수 있는 기능
protocol Sendable {
var from: Sendable { get }
var to: Receiveable? { get }
func send(data: Any)
static func isSendableInstance(_ instance: Any) -> Bool
}
extension Sendable {
// 발신은 발신 가능한 객체, 즉 Sendable 프로토콜을 준수하는 타입의 인스턴스여야 한다.
var from: Sendable {
return self
}
// 메시지를 발신한다.
func send(data: Any) {
guard let receiver: Receiveable = self.to else {
print("Message has no receiver")
return
}
// 수신 가능한 인스턴스의 received 메서드를 호출한다.
receiver.received(data: data, from: self.from)
}
static func isSendableInstance(_ instance: Any) -> Bool {
if let sendableInstance: Sendable = instance as? Sendable {
return sendableInstance.to != nil
}
return false
}
}
// 수신, 발신이 가능한 Message 클래스
class Message: Sendable, Receiveable {
var to: Receiveable?
}
// 수신, 발신이 가능한 Mail 클래스
class Mail: Sendable, Receiveable {
var to: Receiveable?
}
// 두 Message 인스턴스를 생성한다.
let myPhoneMessage: Message = Message()
let yourPhoneMessage: Message = Message()
// 아직 수신받을 인스턴스가 없다.
myPhoneMessage.send(data: "Hello") // Message has no receiver
// Message 인스턴스는 발신과 수신이 모두 가능하므로 메시지를 주고 받을 수 있다.
myPhoneMessage.to = yourPhoneMessage
myPhoneMessage.send(data: "Hello") // Message received Hello from Message
// Mail 인스턴스를 두 개 생성한다.
let myMail: Mail = Mail()
let yourMail: Mail = Mail()
myMail.send(data: "Hi") // Mail has no receiver
// Message와 Mail 모두 Sendable과 Receiveable 프로토콜을 준수하므로
// 서로 주고 받을 수 있다.
myMail.to = yourMail
myMail.send(data: "Hi") // Mail received Hi from Mail
myMail.to = myPhoneMessage
myMail.send(data: "Bye") // Message received Bye from Mail
// String은 Sendable 프로토콜을 준수하지 않는다.
Message.isSendableInstance("Hello") // false
// Message와 Mail은 Sendable 프로토콜을 준수한다.
Message.isSendableInstance(myPhoneMessage) // true
// yourPhoneMessage는 to 프로퍼티가 설정되지 않아서 보낼 수 없는 상태다.
Message.isSendableInstance(yourPhoneMessage) // false
Mail.isSendableInstance(myPhoneMessage) // true
Mail.isSendableInstance(myMail) // true
[코드 23-1]의 Message와 Mail 클래스는 Receiveable과 Sendable 프로토콜을 채택하고 있지만, 실제로 구현한 것은 저장 인스턴스 프로퍼티인 to뿐이다. 그 외의 기능은 이미 각 프로토콜의 익스텐션에 구현되어 있다.
프로토콜을 정의할 때는 요구사항 정의만 가능하다. 그러나 프로토콜의 익스텐션에는 프로토콜이 요구하는 기능을 실제로 구현해줄 수 있다. 다만 익스텐션에는 저장 프로퍼티를 구현할 수 없으므로 저장 프로퍼티는 각각의 타입에서 직접 구현해야 한다. 이렇게 프로토콜과 익스텐션을 결합하면 코드의 재사용성이 월등히 증가한다.
이처럼 프로토콜의 요구사항을 익스텐션을 통해 구현하는 것을 프로토콜 초기구현(Protocol Default Implementation)이라고 한다.
그런데 만약 프로토콜의 익스텐션에서 구현한 기능을 사용하지 않고 타입의 특성에 따라 조금 변경해서 구현하고 싶다면 재정의하면 된다.
/*
코드 23-2. 익스텐션을 통해 구현된 메서드 재정의
*/
class Mail: Sendable, Receiveable {
var to: Receiveable?
func send(data: Any) {
print("Mail의 send 메서드는 재정의되었습니다.")
}
}
let mailInstance: Mail = Mail()
mailInstance.send(data: "Hello") // Mail의 send 메서드는 재정의되었습니다.
사실 [코드 23-2]의 send(data:) 메서드를 구현한 것은 재정의라고 할 수 없다. 이미 프로토콜을 준수하는 타입의 메서드를 호출했기 때문이다. 특정 프로토콜을 준수하는 타입에 프로토콜의 요구사항을 찾아보고 이미 구현되어 있다면 그 기능을 호출하고, 그렇지 않다면 프로토콜 초기구현의 기능을 호출한다.
여기에 제네릭까지 더한다면 코드의 재사용성은 훨씬 더 좋아질 것이다. [코드 22-16]부터 [코드 22-20]까지 작성해보았던 코드를 제네릭, 프로토콜, 익스텐션을 적절히 융합하여 재사용에 조금 더 용이하도록 변경해보았다.
/*
코드 23-3. 제네릭, 프로토콜, 익스텐션을 통한 재사용 가능한 코드 작성
*/
protocol SelfPrintable {
func printSelf()
}
extension SelfPrintable where Self: Container {
func printSelf() {
print(items)
}
}
protocol Container: SelfPrintable {
associatedtype ItemType
var items: [ItemType] { get set }
var count: Int { get }
mutating func append(item: ItemType)
subscript(i: Int) -> ItemType { get }
}
extension Container {
mutating func append(item: ItemType) {
items.append(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> ItemType {
return items[i]
}
}
protocol Popable: Container {
mutating func pop() -> ItemType?
mutating func push(_ item: ItemType)
}
extension Popable {
mutating func pop() -> ItemType? {
return items.removeLast()
}
mutating func push(_ item: ItemType) {
self.append(item: item)
}
}
protocol Insertable: Container {
mutating func delete() -> ItemType?
mutating func insert(_ item: ItemType)
}
extension Insertable {
mutating func delete() -> ItemType? {
return items.removeFirst()
}
mutating func insert(_ item: ItemType) {
self.append(item: item)
}
}
struct Stack<Element>: Popable {
var items: [Element] = [Element]()
}
struct Queue<Element>: Insertable {
var items: [Element] = [Element]()
}
[코드 23-3]에서 Container 프로토콜은 연관 타입을 활용하여 제네릭에 더욱 유연하게 대응할 수 있도록 정의했다. Popable과 Insertable 프로토콜은 Container 프로토콜을 상속받아 추가로 특정 목적을 갖는 컨테이너 타입에 필요한 기능을 요구한다. 그러나 이 프로토콜들도 초기구현을 통해 미리 공통 기능을 구현했기에 실제 프로토콜을 따르는 타입은 추가 구현이 필요 없다.
각각의 요소 타입은 제네릭을 통해 사용할 때 결정하므로 타입에 대해 매우 유연하게 동작할 수 있다. 이는 클래스의 상속보다도 훨씬 강력하게 기능의 단위를 공유할 수 있는 방법으로 사용한다. 프로토콜 초기구현을 한 프로토콜을 채택했다면 상속도 추가 구현도 필요 없다. 또한 초기구현을 한 프로토콜만 채택한다면 기능이야 얼마든지 추가할 수 있다.
이처럼 프로토콜 초기구현을 통해 기능을 구현한다면 프로토콜 채택만으로 타입에 기능을 추가해 사용할 수 있다. 이것이 프로토콜 지향 프로그래밍의 핵심 콘셉트 중 하나이다. 실제로 스위프트의 많은 기능은 프로토콜, 익스텐션, 제네릭의 조합으로 구현되어 있다.
23.3 맵, 필터, 리듀스 직접 구현해보기
맵은 컨테이너가 담고 있던 각각의 값을 매개변수를 통해 받은 함수에 적용한 후 다시 컨테이너에 포장하여 반환하는 함수이다. 필터는 컨테이너 내부의 값을 걸러서 추출해 새로운 컨테이너에 값을 담아 반환하는 함수이다. 리듀스는 컨테이너 내부의 콘텐츠를 하나로 합쳐주는 기능을 실행하는 함수이다.
지금부터 [코드 23-3]에서 구현해본 Stack 컨테이너 타입에 맵, 필터 리듀스를 구현해 보자.
먼저 Array에서 맵을 사용했던 간략한 예를 살펴보자.
/*
코드 23-5. Array 타입의 맵 사용
*/
let items: Array<Int> = [1, 2, 3]
let mappedItems: Array<Int> = items.map { (item: Int) -> Int in
return item * 10
}
print(mappedItems) // [10, 20, 30]
Int 타입이 요소로 저장된 Array 제네릭 타입 컨테이너(Array<Int>)에 맵 메서드를 호출하면 똑같이 Array<Int> 타입의 결과물을 반환한다. Stack<Int>는 맵 메서드를 통해 Stack<Int>를 반환받는다고 생각할 수 있다. Stack 컨테이너에 맵 메서드에서 전달받은 함수를 사용한다고 생각해보면 자신이 갖는 요소의 타입인 Element를 특정한 타입으로 변환시키는 함수를 전달받으면 된다. 그리고 맵 메서드의 반환은 변경된 타입을 요소로 갖는 Stack 타입이 될 것이다.
[코드 23-6]은 [코드 23-3]에 추가될 코드이다.
/*
코드 23-6. Stack 구조체의 맵 메서드
*/
// Stack 구조체 구현부
func map<T>(transform: (Element) -> T) -> Stack<T> {
var transformedStack: Stack<T> = Stack<T>()
for item in items {
transformedStack.items.append(transform(item))
}
return transformedStack
}
// Stack 구조게 구현부 외부
var myIntStack: Stack<Int> = Stack<Int>()
myIntStack.push(1)
myIntStack.push(5)
myIntStack.push(2)
myIntStack.printSelf() // [1, 5, 2]
var myStrStack: Stack<String> = myIntStack.map{ "\($0)" }
myStrStack.printSelf() // ["1", "5", "2"]
/*
코드 23-7. Array 타입의 필터 사용
*/
let items: Array<Int> = [1, 2, 3]
let filteredItems: Array<Int> = items.filter { (item: Int) -> Bool in
return item % 2 == 0
}
print(filteredItems) // [2]
[코드 23-7]에서 볼 수 있듯이 Array 타입의 필터 또한 자신과 동일한 모양의 Array 타입을 반환해준다. [코드 23-8]처럼 Stack 구조체에 필터도 구현해보자.
/*
코드 23-8. Stack 구조체의 필터 메서드
*/
// Stack 구조체 구현부
func filter(includeElement: (Element) -> Bool) -> Stack<Element> {
var filteredStack: Stack<ItemType> = Stack<ItemType>()
for item in items {
if includeElement(item) {
filteredStack.items.append(item)
}
}
return filteredStack
}
// Stack 구조체 구현부 외부
let filteredStack: Stack<Int> = myIntStack.filter { (item: Int) -> Bool in
return item < 5
}
filteredStack.printSelf() // [1, 2]
/*
코드 23-9. Array 타입의 리듀스 사용
*/
let items: Array<Int> = [1, 2, 3]
let combinedItems: Int = items.reduce(0) { (result: Int, next: Int) -> Int in
return result + next
}
print(combinedItems) // 6
let combinedItemsDoubled: Double = items.reduce(0.0) { (result: Double, next: Int) -> Double in
return result + Double(next)
}
print(combinedItemsDoubled) // 6.0
let combinedItemsString: String = items.reduce("") { (result: String, next: Int) -> String in
return result + "\(next) "
}
print(combinedItemsString) // "1 2 3"
리듀스는 전달인자로 전달받은 초깃값과 처리함수를 통해 초깃값과 동일한 타입의 결과를 반환한다.
/*
코드 23-10. Stack 구조체의 리듀스 메서드
*/
// Stack 구조체 구현부
func reduce<T>(_ initialResult: T, nextPartialResult: (T, Element) -> T) -> T {
var result: T = initialResult
for item in items {
result = nextPartialResult(result, item)
}
return result
}
// Stack 구조체 구현부 외부
let combinedInt: Int = myIntStack.reduce(100) { (result: Int, next: Int) -> Int in
return result + next
}
print(combinedInt) // 108
let combinedDouble: Double = myIntStack.reduce(100.0) { (result: Double, next: Int) -> Double in
return result + Double(next)
}
print(combinedDouble) // 108.0
let combinedString: String = myIntStack.reduce("") { (result: String, next: Int) -> String in
return result + "\(next) "
}
print(combinedString) // "1 5 2 "
이처럼 제네릭은 타입에 한정되지 않도록 다양한 기능을 구현한다.
23.3 기본 타입 확장
프로토콜 초기구현을 통해 스위프트의 기본 타입을 확장하여 내가 원하는 기능을 공통적으로 추가해볼 수도 있다. 스위프트 표준 라이브러리에 정의되어 있는 타입은 실제 구현코드를 보고 수정할 수 없기 때문에 익스텐션, 프로토콜, 프로토콜의 초기구현을 사용해 기본 타입에 기능을 추가해볼 수 있다.
/*
코드 23-11. SelfPrintable 프로토콜의 초기구현과 기본 타입의 확장
*/
protocol SelfPrintable {
func printSelf()
}
extension SelfPrintable {
func printSelf() {
print(self)
}
}
extension Int: SelfPrintable { }
extension String: SelfPrintable { }
extension Double: SelfPrintable { }
1024.printSelf() // 1024
3.14.printSelf() // 3.14
"hana".printSelf() // "hana"
'iOS > SWIFT' 카테고리의 다른 글
[Swift] Chapter 25. 패턴 (0) | 2021.10.23 |
---|---|
[Swift] Chapter 24. 타입 중첩 (0) | 2021.10.13 |
[Swift] Chapter 22. 제네릭 (0) | 2021.10.13 |
[Swift] Chapter 21. 익스텐션 (0) | 2021.10.11 |
[Swift] Chapter 20. 프로토콜 (0) | 2021.10.11 |
댓글