Computer Science
탄탄한 기반 실력을 위한
전공과 이론 지식 모음
Today I Learned!
배웠으면 기록을 해야지
TIL 사진
Flutter 사진
Flutter로 모바일까지
거꾸로캠퍼스 코딩랩 Flutter 앱개발 강사
스파르타코딩클럽 즉문즉답 튜터
카카오테크캠퍼스 3기 학습코치
프로필 사진
박성민
임베디드 세계에
발을 들인 박치기 공룡
임베디드 사진
EMBEDDED SYSTEM
임베디드 SW와 HW, 이론부터 실전까지
ALGORITHM
알고리즘 해결 전략 기록
🎓
중앙대학교 소프트웨어학부
텔레칩스 차량용 임베디드 스쿨 3기
애플 개발자 아카데미 1기
깃허브 사진
GitHub
프로젝트 모아보기
Instagram
인스타그램 사진

Develop/iOS 개발

[Swift] 스위프트는 Protocol-Oriented Programming 을 지향한다!

sm_amoled 2024. 3. 24. 21:42

이번에 야곰 리펙토링 강의를 들으면서 Swift의 “프로토콜” 이 다른 언어에 비해 굉장히 특이한 컨셉을 가지고 있다는 걸 알았다. 어떻게 보면 abstract class나 interface를 통해 구현하는 OOP의 개념들이 프로토콜에 담겨있는데, 이게 구조체나 enum 타입에 모두 적용되게 한다는게 꽤나 놀라운 일이라고 생각된다. 구조체는 값 타입이라며!!! enum도 값 타입이라며!!!

 

내용이 나왔던 김에 강의에서 소개해준 관련된 WWDC 영상들을 보면서 어떤 컨셉으로 설계되었고, 어떻게 동작하는 건지 공부하면서 한 번 정리해보려고 한다.


What I Learned

이번에 본 [ WWDC 15 의 Protocol-Oriented Programming in Swift ] 는 이미 많은 사람들이 블로그에 정리해둔 스위프트 WWDC의 기본같은 영상인 것 같다.

 

프로토콜에 대한 내용을 쭉 들어봤을 때, class의 상속관계를 사용할 때 보다 기존 코드의 확장이 필요한 시점에 프로토콜을 활용하면 원본 코드를 건드리지 않으면서 훨씬 더 자유롭게 확장을 할 수 있고, 다른 인스턴스와 상호작용을 하는 경우 해당 인스턴스의 타입이나 채택한 프로토콜에 따라 동작을 분기할 수도 있도록 설계되어있다. 요것 뿐만 아니라 클래스의 첫 설계 시점에 상속할 부모 클래스를 선택하는 것과 다르게 extension과 프로토콜 채택을 통해 “기능 확장이 필요한 시점에 채택이 이루어질 수 있다”는 것도 확장성에서 큰 장점으로 작용되는 특징이라고 느꼈다.

 

내부적인 동작 방식에 대해서는 또 다른 WWDC 영상에서 소개가 되고있는데, [ Understanding Swift Performance ] 라는 영상도 곧 들어볼 예정이다! 기대기대


우리가 클래스를 사용하는 이유

Encapsulation, Access Control, Abstraction, Namespace 분리, Expressive Syntax, Extensibility 등의 목적으로 클래스를 사용하도록 배웠다.

그러나 여기에 있는 목적들은 사실 Struct를 통해서도 만들어낼 수 있다.

 

클래스의 단점?

  • Implicit Sharing
    • reference를 통한 값 접근 방식에 의해 race condition, lock 등의 문제가 발생할 수 있음
    • 그치만, 사실 swift의 collection 들은 모두 값 타입으로 구성되어 있기 때문에 요런게 문제가 되지는 않음 ㅎㅎ
  • 상속의 불편한 점
    • 단일 상속으로 인해 하나의 부모 클래스만 가질 수 있기 때문에, 클래스의 기능 확장 시점이 아닌 처음 만들 때 부모 클래스를 잘 선택해야함
    • 자식 클래스 init 시점에 부모 클래스의 init이 모두 호출되어야함
    • 요것도 사실은 swift에서는 delegate pattern으로 컨트롤 해주고 있는 문제
  • 타입 관계
    • 자식 클래스에서 부모 클래스의 함수를 끌어다 사용하더라도, 자식의 프로퍼티를 활용하기 어려움 (냄새나는 코드를 작성하기 쉬움)
class Ordered {
    func precedes (other: Ordered) -> Bool { fatalError("implement me!") }
}

class Label: Ordered { var text: String = "" ⋯ }

class Number : Ordered {
    var value: Double = 0

    override func precedes (other: Ordered) -> Bool {
        // other가 Number인지 확인해야함
        // -> type safety hole
        return value ‹ (other as! Number).value
    }
}

 

더 나은 추상화 방법이 필요하다

  • 값 타입을 지원해야함
  • 정적 타입 관계 (static type relationship)과 동적 디스패치(dynamic dispatch)를 지원해야함
    • 요건 바로 위의 타입 관계에서의 문제 해결을 말함
  • 단일 상속이 아닌 복수개의 기능을 가져올 수 있어야 함
  • retroactive modeling이 가능해야함
    • 원본을 수정하지 않은 채로 확장판을 만들어낼 수 있어야 함
  • 모델에게 데이터 초기화와 인스턴스 데이터에 대한 책임을 넘기지 않아야 함
  • 무엇을 구현해야 하는지 정확하게 명시해야함

⇒ 이를 위해 Swift 에서는 Protocol 을 사용한다.

 

프로토콜 활용해서 개선하기

위에서 봤던 코드를 프로토콜을 사용하는 방식으로 개선하자면 아래처럼 된다.

protocol Ordered {
    // 프로토콜은 함수를 구현하지 않음
    // Self를 통해 프로토콜이 아닌 프로토콜을 채택한 구현체의 타입을 반영할 수 있음
    func precedes (other: Self) -> Bool 
}

class Label: Ordered { var text: String = "" ⋯ }

// protocol은 구조체에서도 채택할 수 있음
// -> ref로 인해 발생하는 문제 제거 가능
struct Number : Ordered {
    var value: Double = 0

    override func precedes (other: Number) -> Bool {
        return value ‹ other.value
    }
}

Self 처럼 타입을 고정하거나 Generic을 통해 하나의 타입을 가진 Array로 강제할 수 있다. 이를 Homogeneous Collection 이라고 부름!

// 기존 방식에서는 sortedKeys에 Ordered를 상속받는 여러 타입이 
// 하나의 배열에 섞여서 한 번에 들어오는 문제가 발생할 수 있음
func binarySearch(sortedKeys: [Ordered], forKey k: T) -> Int { ... }

// swift에서 generic을 사용해주면 protocol의 함수이더라도 
// 프로토콜의 구현체 하나의 타입만 들어오도록 만들 수 있음
func binarySearch<T : Ordered>(sortedKeys: [T], forKey k: T) -> Int { ... }

프로토콜을 사용하면 장점

  • 기존 코드를 건드리지 않고 확장 수정이 가능하다 (retroactive modeling)
    • 기존 코드에 “새로운 기능을 추가한다”는 코드를 넣지 않더라도 쉽게 확장이 가능함
       
     

protocol newFeature {
	func 새로운기능() -> 반환값
}

extension originFeature: newFeature {
	func 새로운기능() -> 반환값 { 구현 }
}
  • 확장에 대한 컨트롤이 쉽다
protocol MyProtocol {
	func 새로운기능() -> 반환값
}

extension MyProtocol {
	func 새로운기능() -> 반환값 { 구현1 }
	func 또다른기능() -> 반환값 { 구현2 }
} 

extension OriginFeature: myProtocol {
	func 새로운기능() -> 반환값 { 구현3 }
	func 또다른기능() -> 반환값 { 구현4 }
}

// 함수 실행 시 OriginFeature의 Extension에서 함수를 찾아 실행한다.
let origin = OriginFeature()
origin.새로운기능() => 구현3 실행
origin.또다른기능() => 구현4 실행

// MyProtocol 타입으로 정한다면, MyProtocol에서 함수 명세를 찾게됨
// 이때 또다른 기능은 MyProtocol에 정의되어있지 않으므로, 
// MyProtocol의 Extension에서 함수를 찾아 실행하게 된다.
let new: MyProtocol = OriginFeature()
new.새로운기능() => 구현3 실행
new.또다른기능() => 구현2 실행

 

    • 사실 요건 이번에 WWDC를 보면서 처음 알게된 방식임… 오 신기하다 ㅋㅋㅋㅋ
    •   extension MyType where T : BType {
            func indexOf(element: T) -> Index? { ... }
        }
      
        -> T가 BType을 따를 때에만 MyType에서 indexOf 함수를 사용할 수 있음
    • retroactive와 조건부 확장을 함께 쓰면 이런 것도 가능함
        protocol Drawable { 
            func isEqualTo(other: Drawable) -> Bool
            func draw()
        }
      
        extension Drawable where Self : Equatable {
            // Equatable인 타입만 받아왔기 때문에, == 연산자는 사용이 가능한 상황
            // 이때, Drawable 끼리의 비교가 필요한 상황!
            // 파라미터를 Self 타입으로 받아버리면 같은 타입끼리만 비교 가능 ... X
      
            func isEqualTo(other: Drawable) -> Bool {
                // 만약 현재 인스턴스와 other 인스턴스가 같은 타입이라면
                // == 을 통해 값을 비교하기
                if let o = other as? Self { return self == o }
                // 만약 다른 경우에는 비교 의미가 없으므로 false 반환
                return false
            }
        }
    • 프로토콜을 채택한 타입에 대한 정보를 가져와 함수 실행에 영향을 줄 수도 있음

protocol Drawable { 
	func isEqualTo(other: Drawable) -> Bool
	func draw()
}

extension Drawable where Self : Equatable {
	// Equatable인 타입만 받아왔기 때문에, == 연산자는 사용이 가능한 상황
	// 이때, Drawable 끼리의 비교가 필요한 상황!
	// 파라미터를 Self 타입으로 받아버리면 같은 타입끼리만 비교 가능 ... X
	
	func isEqualTo(other: Drawable) -> Bool {
		// 만약 현재 인스턴스와 other 인스턴스가 같은 타입이라면
		// == 을 통해 값을 비교하기
		if let o = other as? Self { return self == o }
		// 만약 다른 경우에는 비교 의미가 없으므로 false 반환
		return false
	}
}

 

클래스는 언제 써야하냐

  • Implicit Sharing이 필요할 때 (copy가 아닌 reference로 접근이 필요할 때)
  • 외부 환경에 인스턴스의 lifetime이 엮여있는 경우 (외부에서 수정이 가능 … reference)
  • 인스턴스가 단순히 write-only conduit 목적(데이터 전달용)으로 사용되는 경우
320x100