 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 6 Review Part 1: Protocols Shapes

singularis7 2021. 8. 25. 16:11
반응형

Protocol

프로토콜은 구현 내용이 제거된 struct 혹은 class이다. 함수나 변수가 정의되었지만 내용이 없는 코드는 다음과 같이 생겼다.

protocol Moveable {
    func move(by: Int)
    var hasMoved: Bool { get }
    var distanceFromStart: Int { get set }
}

프로토콜을 정의해두면 다른 struct나 class가 해당 프로토콜을 포함하여 명세에 대한 구현을 할 수 있다.

struct PortableThing: Moveable {
    // must implement move(by:), hasMoved and distanceFromStart here
}

결국 어느 struct나 class가 어느 프로토콜을 포함하여 정의된다는 것은 다음과 같이 생각해볼 수 있는데 어떻게 생각하든지 프로토콜에 정의된 함수나 변수에 대한 구현을 한다는 점은 변하지 않는다.

  • 어느 프로토콜 처럼 동작한다 (struct || class behaves like protocol)
  • 어느 프로토콜을 준수하는 것처럼 동작한다 (struct || class conforms to protocol)

새로운 프로토콜을 정의할 때 이전에 존재하는 다른 프로토콜을 함께 구현하도록 요구하는 프로토콜을 정의할 수도 있다. "Protocol Inheritance" 라고 불리며 이 프로토콜을 만족하는 struct나 class는 상속받은 프로토콜에 대한 내용을 포함한 모든 명세를 만족시키는 구현을 포함해야 한다. 예시는 다음과 같다.

protocol vehicle: Moveable {
    var passengerCount: Int { get set }
}

class Car: Vehicle {
    // must implement move(by:), hasMoved, distanceFromStart and passengerCount
}

What is a protocol used for?

실제로 Swift에서 프로토콜은 아주 다양한 방법으로 사용되고 있다.  많이 사용되는 개념과 보기 어려운 개념을 구분하며 공부해나갈 것이다.

자주 사용되지 않지만 프로토콜은 타입으로 사용될 수 있지만 모든 프로토콜에서 이와 같은 방식을 사용할 수 없다는 제약이 존재한다.

func travelAround(using moveable: Moveable) // can pass a Car || PortableThing here!
let foo = [Moveable] // this Array can now contain Cars and also PortableThings

예를들어 View, Equatable, Identifiable에서는 작동하지 않는다. 이제 것 접해온 대부분의 프로토콜에서는 동작하지 않는 방식인 것 같다. 사실 이런 제약은 SwiftUI를 설계할 때 의도된 제약이며 여튼 프로토콜을 위와 같은 방식으로 사용하지 않을 가능성이 크다.

프로토콜이 가장 많이 사용되는 방식은 struct, class 혹은 enum에 대한 behavior를 명시하는 것이다. 

// struct behaves like View
struct EmojiMemoryGameView: View { ... }

// class behaves like ObservableObject
class EmojiMemoryGame: ObservableObject { ... }

View 프로토콜을 만족하는 구조체의 경우 var body를 구현함으로써 UI를 설계할 수 있었으며 ObservableObject 프로토콜을 만족하는 class의 경우 구현해야할 별도의 항목은 없었지만 MVVM 패턴에서 Model의 변동 여부를 publish 하기위해 objectWillChange 라는 변수를 활용할 수 있게되었다. 이 변수는 사용자를 위해 자동으로 제공되기 때문에 사용할 수 있는 것이다. 

Identifiable, Hashable, Equatable, CustomStringConvertible과 같이 동작하도록 만들어주는 가벼운 작은 단위의 프로토콜이 존재한다. 추후에 Animatable이라는 프로토콜도 보게 될 것이다.

Declair Restriction

Protocol은 Generic과 함께 사용되기도 한다. Generic은 struct, class 혹은 enum에 대하여 type을 신경쓰지 않겠다고 선언할 때 사용되는데 이 타입에 제약 사항?, 특정 조건에 대하여 명시할 수 있다. 즉, "약간은 신경쓸께요" 로 명시하는 것이다.

// Heart of "protocol-oriented programming"
struct MemoryGame<CardContent> where CardContent: Equatable

예를 들어 MemoryGame을 generic struct로 선언해 두었다면 generic type으로 Int, String 같은 기본적인 타입이 들어올 수도 있지만 사용자가 임의로 정의한 다른 타입이 들어올 수도 있다. 분명 타입별로 사용할 수 있는 연산에 제약이 있을 수도 있다. 

위와 같이 Equatable 프로토콜을 추기해두면 이 구조체를 정의할 때 사용할 수 있는 타입이 "==" 연산을 사용할 수 있는 것임을 보장할 수 있게된다. 당연히 구조체 내부에서 제네릭 타입 변수에 대하여 마찬가지로 "==" 연산을 사용할 수 있게될 것이다.

프로토콜은 extension에서 특정 프로토콜을 만족하는 type에서만 사용될 수 있도록 규정하는 용도로 사용될 수 있다.

extension Array where Element: Hashable { ... }

이전 Demo App을 구현할 때 extension을 사용하여 Array에 oneAndOnly 변수를 추가했던 기억이 있을 것이다. 배열 내부에 있는 요소를 찾는등의 기능을 구현하려면 배열의 타입이 "Hashable"할 때 가능할 것이다. 위와 같이 타입에 대하여 특정 프로토콜로 조건을 걸어주면 모든 배열이 아닌 해당 프로토콜을 만족하는 타입으로 정의된 배열에서만 extentsion에 구현된 기능을 사용할 수 있도록 제한할 수 있다. 

프로토콜은 function에서도 특정 프로토콜을 만족하는 type에서만 사용될 수 있도록 규정하는 용도로 사용될 수 있다.

init(data: Data) where Data: Collection, Data.Element: Identifiable

생성자 함수를 예시로 들었는데 위와 같이 정의하면 제네릭 타입의 Data에 대하여 우선 이 타입이 Collection 프로토콜(예를 들어: String, Array)을 만족해야한다. 또한 Data는 콜렉션을 만족하기 때문에 배열 내부에 특정 데이터 타입을 만족하는 자료가 들어가게 될 것인데 내부 제네릭 데이터 타입을 의미하는 Element에 대하여 Identifiable한 프로토콜을 만족하는 경우 위 생성자를 사용할 수 있게된다. 

내가 정의하고자하는 함수에 대하여 제약사항을 추가할 수 있는 아주 강력한 방법이며 위에서는 init에 대해서만 살펴보았지만 다른 어느 함수에서나 사용할 수 있는 방법이기도 하다.

프로토콜은 제약 사항을 설정하는 것 뿐만 아니라 두개의 entity 간의 계약을 설정하는데 사용할 수 있다.?????

Drop Delegate라는 프로토콜이 있다. 예를들어 바탕화면에 있는 아이콘을 집어서 다른 위치에 배치시킬 때 Drag and Drop을 사용할 것이다. Drop Delegate 프로토콜에는 Drag and Drop을 구현해야 할 때에 요구되는 모든 기능들이 명시되어 있다. 

이 경우에 프로토콜은 요구되는 기능을 명시하는 일 외에 추가적인 일들을 하지는 않는다. 코드로 하여금 drop을 하는  요청을 받았을 때 "어 알았어! 내가 받아줄께!" 이런식으로 돌아간다. 즉 너는 나에게 drop을 관리하라고 시킬 수 있어 그럼 내가 다 해줄께 이런 느낌이란다! (확실히 이해되지는 않는다)

Extension Protocol

프로토콜의 가장 강력한 용도중 하나는 Code Sharing을 쉽게 만들어준다는 점이다. 근데 생각해보면 이상한 면이 있다. Protocol에는 구현 내용이 포함되어있지 않다면서 왜 코드의 공유가 쉬워진다고 말하는 것인가? 지금부터 집중해야한다. 

Implementation can be added to a protocol by creating an extension to it
프로토콜에 대한 extentsion을 정의하여 프로토콜에 구현을 추가할 수 있습니다!

이전 데모앱에서 Array에 extentsion을 추가한 것처럼 protocol에 extentsion을 추가하면 프로토콜에 정의된 함수나 변수는 뭔가를 구현하는 실제 코드를 가질 수 있게된다. 

실제로 SwiftUI의 View 프로토콜에서 foregroundColor, font 와 같은 modifier를 정의할 때 이런 방식으로 구현되며 Array나 Dictionary 같은 Collection에 filter, firstIndex와 같은 메서드가 구현되는 방식이기도 하다.

extentsion을 활용하면 프로토콜에대하여 기본적인 구현을 정의할 수 있는데, 앞서본 ObservableObject에서 objectwillchange를 제공해주는 원리와 동일하다. Apple에 있는 어느 고마우신 분이 이 변수에 대한 구현을 extension을 통해 정의해주었기 때문에 그냥 가져다가 사용할 수 있게 된 것이다. 

이러한 방식으로 Code Sharing을 위해 프로토콜에 extension을 추가하는 것이 "protocol-oriented programming"의 핵심이다. 주로 개발자는 Apple에서 protocol extension 을 통해 추가한 코드를 사용하게 될 것이며 내부에서 어떤일이 일어나고 있는지 이해해 보는 것이 필요하다.

관련된 몇가지 예시를 살펴보자! Array, Range, String, Dictionary와 같은 콜렉션에 filter 기능을 추가해보는 것이 목표이다.

func filter(_ isIncluded: (Element) -> Bool) -> Array<Element>

isIncluded 클로져가 true를 return 하는 항목을 새로운 배열에 담아 그 배열을 리턴하는 함수이다. 이 함수는 Apple에서 작성한 단 하나의 코드로만 존재한다. 하지만 신기하게도 Array 혹은 Dictionary와 같은 여러가지 자료형에서 사용할 수 있다. 어떻게 이런 설계가 가능했을까?

  • Filter 메서드는 Foundation 라이브러리에 Sequence 프로토콜의 extension으로 정의되어있다. 
  • Sequence 프로토콜은 Array, String, Dictionary, Set 모두가 준수하는 프로토콜이다.
  • 따라서 위와 같은 자료형은 Sequence 프로토콜에 정의된 메서드를 얻을 것이며
  • Sequence에 정의된 Filter 메서드도 얻게 될 것이다.

위와 같은 원리를 기존의 Object-Oriented Programming을 사용하여 구축했을 때에는 공통적인 base class 에서 상속받는 식으로 특정 클래스를 만족시키도록 설계해야 했다. 특이 Single Inheritance의 경우 매우 지저분하고 복잡하다. 이 프로토콜 매커니즘은 OOP보다 훨씬 더 간단한 방법이다.

프로토콜을 사용하여 작업을 수행하는 방법을 쭈루루루루룩 소개해 보았다.  잠시 Protocol에 대하여 constrainsgain에 관하여 적어보도록 하겠다.

struct Tesla: Vehicle {
    // Tesla is constrained to have to implement everything in Vehicle
    // but it gains all the capabilities a Vehicle has too
}

예를 들어 Tesla 구조체가 정의될 때 Vehicle 프로토콜을 만족시키도록 정의하려면 Vehicle에 명세에 포함된 각족 변수나 함수에 대해 구현하는 내용이 모두 포함되어야 하기에 분명 제약 사항이 존재한다. 하지만 Vehicle 프로토콜을 추가하여이 프로토콜에 extension된 기능을 모두 얻을 수 있다.

extension Vehicle {
    func registerWithDMV() { /* actual implementation here */ }
}

예를 들어 Vehicle 프로토콜에 registerWithDMV 기능의 구현이 추가되어있다면 이 프로토콜을 만족하는 Tesla 구조체도 자연스럽게 이 구현을 사용할 수 있게되는 것이다. 반대로 생각하면 프로토콜에서 제공하는 기능을 갖기위해 주어진 변수와 함수에 대한 구현만 하면 된다는 의미로도 받아드릴 수 있다. 

SwiftUI에서 이와 같은 예시를 찾아보자!

SwiftUI를 사용하여 UI를 설계하려면 View 프로토콜을 만족하는 구조체를 정의해야한다. View 프로토콜을 사용하기 위해 요구되는 것은 var body를 구현하는 것이다. 우리가 body를 구현함으로써 얻게 되는 것은 View 프로토콜의 extension으로 정의된 padding, foregroundColor와 같은 기능을 얻게된다.

Why protocols?

프로토콜은 일종의 방식인데 struct, class, other protocols와 같은 type에게 어떤 능력이 있는지 알려주는 방식이다. 또한 다른 코드가 다른 타입에 대하여 특정 동작을 만족하도록 요구하는 방법이기도 하다.

그러나 어느 쪽도 어떤 종류의 구조체나 클래스인지 공개할 필요가 없다. 우리는 세부 구현 내용을 뒤로 숨긴체 Functionality 즉, 기능 그 자체에 집중하고 있으며 oop에서의 encapsulation 규약에서 더 높은 수준으로 이끌어준다. 

앞으로 HashableEquatable로 이어지는 Identifiable 프로토콜에 대해 자세히 살펴보도록 하자! 이 3가지 프로토콜이 담고 있는 프로토콜에 대한 배울점이 존재한다.

Generics + Protocols

프로토콜도 제네릭 타입을 가질 수 있다. Identifiable 프로토콜에는 제네릭 타입이 포함되어있으며 아래의 코드에서 ID 부분이 제네릭 타입에 해당된다.

protocol Identifiable {
    associatedtype ID
    var id: ID { get }
}

 

다만 struct나 class를 정의할 때 처럼 꺽쇠 ("<", ">")를 사용하여 제네릭 타입을 정의하지 않고 associatedtype 키워드를 사용해서 제네릭 타입을 정의한다.

데오 앱에서 ForEach View를 정의할 때도 보았겠지만 ID는 Hashable 해야한다. (String 콘텐츠를 id로 넣었을때 같은 내용은 서로 구분하지 못하고 있는 오류를 생각해보자!) 그렇다면 위 ID에 넣을 수 있는 타입중에는 Hashable 프로토콜을 만족하는 타입만 넣을 수 있도록 할 필요가 있을 것이다. 어떻게 구현해야할까?

위에서 where 구문을 사용하여 제약 조건을 설정하는 방식을 사용하던, 프로토콜을 명시하는 방법을 사용하던 상관없다! 간단하다!

그렇다면 우리가 사용한 Hashable 프로토콜에 관하여 좀더 자세히 알아보자!

Hashable은 프로토콜인데 hash(into: ) 하나의 함수를 포함있으며 모든 변수를 포함해서 하나의 Hash값으로 변환해주는 기능을 제공한다. Hashable 프로토콜의 사용 예시는 다음과 같다.

struct Foo: Hashable {
    var i: Int
    var s: String
    func hash(into hasher: inout Hasher) {
    	hasher.combine(i)
        hasher.combine(s)
    }
}

Hashable 프로토콜을 지원하는 Foo struct에서는 hash(into:) 함수를 구현하고 있는데 내부에 구조체의 변수값들을 hash값 계산을 위하여 문자열로 결합하기 위해 combine 함수를 사용하고 있다. 

Protocol Inheritance

해시함수에 원리에 대해서 알고 있다면 서로 두 값이 같은 해시값을 가질 수 있음을 알 수 있을 것이다. 따라서 해시값이 동일할 지라도 실제로 두 값이 같은지(Equalaty)에 대해 검증할 필요가 있다. 이렇기 때문에 Hashable은 Equatable 프로토콜을 상속하여 정의되었다. 

protocol Hashable: Equatable {
    func hash(into hasher: inout Hasher)
}

Equatable은 무엇일까? 이미 데모앱에서 CardContent에 대하여 제약조건을 걸 때 Equatable을 사용해본 기억이 있을 것이다. 다시한번 정리하자면 어느 제네릭 타입이 있을 때 해당 타입에 대해 "==" 비교 연산을 지원함을 보장해주며 다음과 같이 연산자 메서드가 정의되어있기 때문에 가능한 일이다. 

protocol Equatable {
    static func ==(lhs: Self, rhs: Self) -> Bool
}

위 코드를 보면 "==" 연산자는 좌항과 우항을 받아서 Boolean 값을 리턴하는 기능을 하는 메서드임을 알 수 있을 것이다. 파라미터를 자세히 보면 "Self" 키워드가 보인다. self는 봤는데 Self는 무엇인가? Self는 Equatable 프로토콜을 구현하는 타입 그 자체를 의미한다. 예를들어 어느 구조체를 정의하는데 Equatable 프로토콜을 만족한다면 Self를 해당 struct type 자체로 변환할 수 있게되는 것이다. 위와 같이 명시함으로써 자동차 타입과 의자 타입을 비교하는 등의 상황을 막을 수 있게 된다.

이제것 소개된 프로토콜을 활용한 설계는 사물을 설계하는데 아주 강력한 방식이다. 이 설계 방법을 배우는 것이 쉬운 방법은 아닌 것도 분명하다. 그런 와중에 좋은 소식은 이 모든 것을 마스터하지 않아도 SwiftUI를 활용해 많은 작업을 수행할 수 있다는 점이다. 일반적으로는 end-point에서 설계된 SwiftUI를 시용하는 위치에 있는 것이다. 

하지만 이 시스템을 사용하면서 이면에 무슨 일이 일어나고 무슨 원리로 동작하고 있는지 알아보기 위해 배운 것이라고 한다. 지금 당장 바로 이 설계시스템을 구현할 수 있을 것으로 기대하지는 않지만 계속 관련 코드를 보면서 결국은 가능해 질 것이다!

앞으로 나올 데모를 경험해보며 소화를 시켜보도록 하자!

반응형