반응형

원문 : https://www.raywenderlich.com/148448/introducing-protocol-oriented-programming



여러분이 레이싱 게임을 개발한다고 상상해 보세요. 차를 운전할수 있고, 오토바이를 달리거나 심지어는 비행기로 날수도 있습니다. 이러한 타입의 앱을 만드는 일반적인 방법은 객체 지향 설계(object oriented design)를 사용하는 것이며, 공통점을 갖는 모든 곳에서 상속받는 객체 내부의 모든 로직을 캡슐화 합니다.

이러한 설계 방식은 동작하지만, 몇가지 단점(drawbacks)이 있습니다. 예를 들어, 가스가 필요한 기계를 만들거나, 백그라운드에서 날고 있는 새, 또는 게임 로직을 공유하길 원하는 능력을 추가한다면, 탈것(vehicles)의 구성요소들을 분리해서 재사용하는 것은 좋은 방법이 아닙니다.

이 시나리오에서 프로토콜(protocols)의 진가를 볼수 있습니다.

Swift는 항상 프로토콜(protocols)을 사용해서 기존 class, struct, enum 타입에 대한 인터페이스를 보장 해줍니다. 이를 통해서 일반적인 상호작용을 할 수 있습니다. Swift 2에서 프로토콜을 확장하고 기본 구현을 제공하는 방법을 도입하였습니다. 마침내, Swift 3에서 표준 라이브러리에서 연산자 적합성(conformance)을 향샹시키고 표준 라이브러리의 새로운 수치(numeric) 프로토콜에 이러한 개선 사항을 사용합니다.

프로토콜은 매우 강력하고 코드 작성 방법을 바꿀수 있습니다. 이 튜토리얼에서, 프로토콜을 만들고 사용하는 방법을 탐구하고, 코드를 더 확장가능하게 만들기 위해 프로토콜 지향 프로그래밍(protocol-oriented programming) 패턴을 사용할 것입니다.

또한, Swift팀이 Swift 표준 라이브러리 자체를 향상시키기 위해서 프로토콜 확장을 어떻게 사용했는지 보고, 작성한 코드에 어떤 영향이 미치는지 볼수 있습니다.

시작하기(Getting Started)

새로운 플레이그라운드(playground)를 만드는 것으로 시작합니다. Xcode에서, File\New\Playground…를 선택하고 플레이그라운드 이름을 SwiftProtocols로 합니다. 여러분은 어떤 플랫폼을 선택할 수 있으며, 이 튜토리얼의 모든 코드는 플랫폼에 구애받지 않습니다. 저장위치를 선택하기 위해 Next를 클릭하고, 마지막으로 Create을 클릭합니다.

새 플레이그라운드가 열리면, 다음 코드를 추가하세요.

protocol Bird {
  var name: String { get }
  var canFly: Bool { get }
}
 
protocol Flyable {
  var airspeedVelocity: Double { get }
}

airspeedVelocity를 정의한 Flyable프로토콜 뿐만아니라 name과 canFly 프로포티를 가진 간단한 프로토콜 Bird를 정의합니다.

프로토콜 이전에는, Flyable를 기본클래스로 시작했을수 있고 Bird와 비행기와 같이 날아다니는 것을 정의하기 위해 객체 상속에 의존했습니다. 여기서 주의할 점은, 모든것(everything)은 프로토콜로 시작합니다!! 이렇게 하면 기본 클래스가 필요없는 방식으로 기능적인 개념을 캡슐화 할 수 있습니다.

다음에 실제 타입 정의를 시작할때, 전체 시스템을 보다 유연하게 만드는 방법을 알게 될것입니다.

프로토콜을 준수하는 타입 정의하기(Defining Protocol-Conforming Types)

플레이그라운드 아래에 다음 struct 정의를 추가하세요.

struct FlappyBird: Bird, Flyable {
  let name: String
  let flappyAmplitude: Double
  let flappyFrequency: Double
  let canFly = true
 
  var airspeedVelocity: Double {
    return 3 * flappyFrequency * flappyAmplitude
  }
}

Bird와 Flyable프로토콜 모두 준수하는 새로운 구조체 FlappyBird를 정의합니다. airspeedVelocity는 flappyFrequency와 flappyAmplitude의 함수로 계산됩니다. flappy는 canFlay에 대해 true를 반환 합니다. :]

다음으로, 플레이그라운드 아래에 다음에오는 두개의 구조체 정의를 추가하세요.

struct Penguin: Bird {
  let name: String
  let canFly = false
}
 
struct SwiftBird: Bird, Flyable {
  var name: String { return "Swift \(version)" }
  let version: Double
  let canFly = true
 
  // Swift is FASTER every version!
  var airspeedVelocity: Double { return version * 1000.0 }
}

Penguin은 Bird이지만, 날수는 없습니다. - 상속으로 접근하는 것을 사용하지 않는것이 좋고, 상속은 모든 새들이 날수 있도록 만듭니다!! 프로토콜을 사용하면 기능 구성요소를 정의할 수 있고 모든 관련된 객체는 프로토콜을 준수해야합니다.

여러분은 이미 몇가지 중복된 것을 볼 수 있습니다. 이미 시스템에 Flyable라는 개념이 있지만, 모든 Bird 타입은 canFly인지 아닌지를 선언해야 합니다.

기본 구현으로 프로토콜 확장하기(Extending Protocols With Default Implementations)

프로토콜 확장으로, 프로토콜에 대한 기본 동작을 정의 할수 있습니다. Bird프로토콜 아래에 다음을 추가하세요.

extension Bird {
  // Flyable birds can fly!
  var canFly: Bool { return self is Flyable }
}

이것은 Flyable 타입일때마다, canFly의 기본동작이 true를 반환하도록 설정하는 Bird의 확장을 정의합니다. 다른 말로, 모든 Flyable 새는 더이상 명시적으로 선언할 필요가 없습니다!!

FlappyBird, SwiftBird, Penguin 구조체 선언에서 let canFly = ...를 삭제합니다. 프로토콜 확장으로 요구사항을 처리한 후에, 플레이그라운드가 성공적으로 빌드된 것을 볼수 있습니다.

왜 기본클래스가 아닌가?(Why Not Base Classes?)

프로토콜 확장과 기본 구현은 다른 언어에서 기본클래스를 사용하거나 추상클래스(abstract classes)를 사용하는 것과 비슷하게 보일수 있지만, Swift에서는 몇가지 장점을 가지고 있습니다.

  • 타입이 하나 이상의 프로토콜을 준수할수 있기때문에, 여러 프로토콜의 기본 동작을 표현(decorated) 할수 있습니다. 일부 언어에서 지원되는 클래스의 다중 상속과는 다르게, 프로토콜 확장은 추가적인 상태를 도입하지 않습니다.
  • 프로토콜을 클래스나 구조체, 열거형에서 사용(adopted) 할 수 있습니다. 기본 클래스와 상속은 클래스 전용(restricted)입니다.

다른 말로, 프로토콜 확장은 클래스뿐만 아니라 value타입에 대한 기본 동작을 정의하는 기능을 제공합니다.

이미 구조체를 사용하는 것을 보았습니다. 다음으로, 플레이그라운드의 끝에 다음에 오는 열거형(enum) 정의를 추가하세요.

enum UnladenSwallow: Bird, Flyable {
  case african
  case european
  case unknown
 
  var name: String {
    switch self {
      case .african:
        return "African"
      case .european:
        return "European"
      case .unknown:
        return "What do you mean? African or European?"
    }
  }
 
  var airspeedVelocity: Double {
    switch self {
    case .african:
      return 10.0
    case .european:
      return 9.9
    case .unknown:
      fatalError("You are thrown from the bridge of death!")
    }
  }
}

다른 모든 값 타입과 마찬가지로, UnladenSwallow가 두개의 프로토콜을 준수하도록 올바른 프로퍼티를 정의하기만 하면 됩니다.

airspeedVelocity와 관련된 이 튜토리얼이 Monty Python 레퍼런스를 포함하지 않을것이라고 생각하나요? :]

기본 동작 재정의하기(Ovrriding Default Behavior)

UnladenSwllow 타입은 Bird프로토콜을 준수해서 canFly에 대한 구현을 자동적으로 가져옵니다. 하지만, UnladenSwallow.unknown이 canFly에 대해 false를 반환하도록 해야 합니다. 기본 구현을 재정의하는것이 가능하나요? 네, 그렇습니다. 플레이그라운드의 끝에 이것을 추가하세요.

extension UnladenSwallow {
  var canFly: Bool {
    return self != .unknown
  }
}

이제 .african과 .european만 canFly에 대해 true를 반환합니다. 플레이그라운드의 끝에 다음을 추가해서 테스트 하세요.

UnladenSwallow.unknown.canFly  // false
UnladenSwallow.african.canFly  // true
Penguin(name: "King Penguin").canFly  // false

이런식으로, 객체 지향 프로그래밍에서 가상 메소드를 사용하는 것처럼 메소드와 프로퍼티를 재정의(override) 할 수 있습니다.

프로토콜 확장하기(Extending Protocols)

표준라이브러리로 부터 프로토콜을 활용하고 기본(default) 동작을 정의할 수 있습니다.

CustomStringConvertible프로토콜을 준수하도록 Bird 프로토콜 선언을 수정합니다.

protocol Bird: CustomStringConvertible {

CustomStringConvertible를 준수하는 것은 타입에 문자열처럼 동작하는 description 프로퍼티가 필요하다는 의미입니다.

현재와 미래의 모든(every) Bird 타입에 이 프로퍼티를 추가해야 한다는 의미인가요?

물론, 프로토콜 확장을 사용하면 더 쉽습니다. Bird정의에 바로 아래 코드를 추가하세요.

extension CustomStringConvertible where Self: Bird {
  var description: String {
    return canFly ? "I can fly" : "Guess I’ll just sit here :["
  }
}

이 확장은 canFly프로퍼티를 각 Bird타입의 description값으로 표현할 것입니다.

그것을 시험해 보기 위해, 플레이그라운드 아래에 다음을 추가하세요.

UnladenSwallow.african

보조 편집기(assitant editor)에 I can fly라은 메시지가 나타납니다. 특히나, 여러분은 여러분의 프로토콜을 확장했습니다!

Swift 표준 라이브러이에서의 효과(Effects on the Swift Standard Library)

프로토콜을 확장이 어떻게 사용자 정의 하고 기능을 확장할 수 있는 훌륭한 방법인지 살펴보았습니다. Swift팀이 Swift 표준 라이브러리 작성을 개선하기 위해 어떻게 프로토콜을 사용할 수 있었는지를 알면 놀랄 것입니다.

플레이그라운드 끝에 다음 코드를 추가 하세요.

let numbers = [10,20,30,40,50,60]
let slice = numbers[1...3]
let reversedSlice = slice.reversed()
 
let answer = reversedSlice.map { $0 * 10 }
print(answer)

이것은 매우 직설적(straightforward)으로 보이고, 출력된 답을 추측할수 있습니다. 놀랄만한것은 연관된 타입입니다. 예를 들어, slice는 정수형 Array가 아니지만 ArraySlice<Int>입니다. 이 특별한 wrapper type 동작은 원래 배열의 뷰 역할을 하고 비싼 메모리 할당을 피하고 빨리 추가할 수 있습니다. 마찬가지로, reversedSlice은 실제적으로 원래 배열로 감싼 타입(wrapper type)인ReversedRandomAccessCollection<ArraySlice<Int>>입니다.



운이 좋게도, 표준 라이브러리를 개발하는 천재가 이 프로토콜을 준수하기 위해 map 메소드를 Sequence 프로토콜과 모든 컬렉션 래퍼(수십 가지가 있음)의 확장으로 정의하였습니다. 이렇게 하면 ReversedRandomAccessCollection만큼이나 쉽게 Array에서 map을 호출 할수 있고 차이가 없습니다. 곧 중요한 디자인 패턴을 사용할 것입니다.

경주에서 벗어나기(Off to the Races)

지금까지 Bird를 준수하는 여러 타입을 정의했습니다. 이제 플레이그라운드의 끝에 완전히 다른것을 추가하세요.

class Motorcycle {
  init(name: String) {
    self.name = name
    speed = 200
  }
  var name: String
  var speed: Double
}

이 클래스는 지금까지 정의한 새나 날수있는것들과 아무런 관련이 없습니다. 하지만 펭귄만큼 오토바이 경주를 원합니다. 이제 모든 조각을 함께 사용 할 시간입니다.

함께 사용하기(Bringing it Together)

경주(racing)에 대해 이러한 다른 타입 모두를 공통 프로토콜로 통일할 때입니다. 원래의 모델 정의를 다시 수정할 수 있습니다. 이것에 대한 멋진(fancy) 용어는 소급 모델링(retroactive Modeling) 입니다. 플레이그라운드에 다음을 추가하세요.

protocol Racer {
  var speed: Double { get }  // speed is the only thing racers care about
}
 
extension FlappyBird: Racer {
  var speed: Double {
    return airspeedVelocity
  }
}
 
extension SwiftBird: Racer {
  var speed: Double {
    return airspeedVelocity
  }
}
 
extension Penguin: Racer {
  var speed: Double {
    return 42  // full waddle speed
  }
}
 
extension UnladenSwallow: Racer {
  var speed: Double {
    return canFly ? airspeedVelocity : 0
  }
}
 
extension Motorcycle: Racer {}
 
let racers: [Racer] =
  [UnladenSwallow.african,
   UnladenSwallow.european,
   UnladenSwallow.unknown,
   Penguin(name: "King Penguin"),
   SwiftBird(version: 3.0),
   FlappyBird(name: "Felipe", flappyAmplitude: 3.0, flappyFrequency: 20.0),
   Motorcycle(name: "Giacomo")
  ]

이 코드에서, 제일먼저 Racer프로토콜을 정의하고 다른 모든 타입들이 준수하도록 만듭니다. Motorcycle 같은 일부 타입들은, 쉽게 준수 합니다. UnladenSwallow같은 다른 것들은 추가 로직이 필요합니다. 결국에는 Racer 타입을 준수해야 합니다.

모든 타입이 준수하고나면, 레이서(racers)의 배열을 만듭니다.

최고 속도(Top Speed)

이제 레이서의 최고 속도를 결정하는 함수를 작성할 시간입니다. 플레이그라운드의 끝에 이것을 추가하세요.

func topSpeed(of racers: [Racer]) -> Double {
  return racers.max(by: { $0.speed < $1.speed })?.speed ?? 0
}
 
topSpeed(of: racers) // 3000

이 함수는 레이서의 가장 빠른 속도를 찾아 반환하는 표준 라이브러리 max를 사용합니다. racers에 대해 빈 배열을 전달하면 0을 반환합니다.

Swift 3 FTW 처럼 보입니다. 마치 의심하는 것처럼! :]

더 일반적으로 만들기(Making it more generic)

이것은 문제가 있습니다. 레이서(racers)의 하위집단(slice)에서 최고 속도를 찾는다고 가정해 봅시다. 이것을 플레이그라운드에 추가하면 오류가 납니다.

topSpeed(of: racers[1...3]) 

Swift는 CountableClosedRange 타입의 인덱스로 [Racer] 타입의 값을 서브스크립트(subscript)로 사용할수 없다는 불평을 합니다. Slicing 은 래퍼 타입중 하나를 반환합니다.

해결책은 실제 Array 대신에 공통 프로토콜에 맞춰 코드를 작성해야 합니다. topSpeed(of:) 호출전에 다음을 추가하세요

func topSpeed<RacerType: Sequence>(of racers: RacerType) -> Double
    where RacerType.Iterator.Element == Racer {
  return racers.max(by: { $0.speed < $1.speed })?.speed ?? 0
}

이것은 약간 무섭게 보일수도 있어서 자세히 살펴봅시다. RacerType은 함수에 대한 제네릭(generic) 타입이고 Swift 표준라이브러리의 Sequence프로토콜을 준수하는 모든 타입이 될수 있습니다. where절은 sequence 요소(element) 타입이 Racer 프로토콜을 준수해야 하는 것을 지정합니다. 모든 Sequence 타입은 Element 타입을 반복할수 있는 Iterator와 연관된 타입을 가지고 있습니다. 실제 메소드 본문은 이전과는 거의 같습니다.

배열 조각(slices)를 포함한 모든 Sequence타입에서 동작합니다.

topSpeed(of: racers[1...3]) // 42

더 Swifty하게 만들기(Making it More Swifty)

좀 더 나아질 수 있습니다. 표준 라이브러리( 에서, topSpeed()을 쉽게 사용할수 있도록, Sequence타입을 확장할수 있습니다. 플레이그라운드 끝에 다음을 추가하세요.

extension Sequence where Iterator.Element == Racer {
  func topSpeed() -> Double {
    return self.max(by: { $0.speed < $1.speed })?.speed ?? 0
  }
}
 
racers.topSpeed()        // 3000
racers[1...3].topSpeed() // 42

이제 이 메소드를 쉽게 사용할수 있지만 racers의 sequences를 처리할때만 적용됩니다.(자동완성)

프로토콜 비교(Protocol Comparators)

Swift 3에서 프로토콜의 한 가지 개선사항은 연산자 요구사항을 만드는 방법입니다.

플레이그라운드 아래에 다음을 추가하세요.

protocol Score {
  var value: Int { get }
}
 
struct RacingScore: Score {
  let value: Int
}

Score프로토콜을 사용한다는 것은 모든 점수(scores)를 동일한 방식으로 처리하는 코드를 작성할수 있다는 것을 의미합니다. 하지만, RacingScore처럼 다른 구체적인 타입을 가짐으로써 스타일 점수(style scores)나 귀여움 점수(cuteness scores)로 이 점수들을 함께(mix up) 사용하지 않아야 합니다. 컴파일러에 감사!

점수가 비교되는 것을 원하므로 누가 높은 점수인지 말할수 있습니다. Swift 3 이전에는, 이 프로토콜을 준수하기 위해 전역 연산자 함수가 필요했었습니다. 이제는 모델의 일부만 정적(static) 메소드를 정의 할수 있습니다. 지금 바로 Score와 RacingScore의 정의를 다음으로 교체하면 됩니다.

protocol Score: Equatable, Comparable {
  var value: Int { get }
}
 
struct RacingScore: Score {
  let value: Int
 
  static func ==(lhs: RacingScore, rhs: RacingScore) -> Bool {
    return lhs.value == rhs.value
  }
 
  static func <(lhs: RacingScore, rhs: RacingScore) -> Bool {
    return lhs.value < rhs.value
  }
}

한 곳에서 RacingScore에 대한 모든 로직을 캡슐화했습니다. 이제 기본 구현을 프로토콜 확장의 마법으로, 점수를 비교할수 있고, 명시적으로 정의하지 않은 더 크거나 같은(greater-than-or-equal-to) 연산자를 사용할 수 있습니다.

RacingScore(value: 150) >= RacingScore(value: 130)  // true

여기에서 어디로 가야 하나요?(Where To Go From Here?)

이 튜토리얼의 모든 코드로 완성된 플레이그라운드를 다운로드 받을수 있습니다. 다운로드

프로토콜 확장을 사용해서 간단한 프로토콜을 만들고 확장하는 프로토콜 지향 프로그래밍(protocol-oriented programming)의 힘을 보았습니다. 기본 구현으로, 기존 프로토콜에 공통적이고 자동 동작을 줄수 있으며, 기본 클래스와 많이 비슷하지만, 구조체와 열거형에도 적용할수 있기 때문에 더 좋습니다.

추가적으로, 프로토콜 확장은 자신의 프로토콜을 확장하는데 사용할 수 없지만, Swift 표준 라이브러리, Cocoa, Cocoa Touch 또는 타사(third party) 라이브러리의 프로토콜에 기본 동작을 제공하고 확장 할 수 있습니다.

프로토콜에 관해서 더 자세해 배우려면, 공식 애플 문서(offical Apple documentation)를 읽어야 합니다.

모든 이론에 대해 더 깊이 보려면, 애플 개발자 포털에 있는 프로토콜 지향 프로그래밍(Protocol Oriented Programming)에 대한 훌륭한 WWDC 세션을 볼 수 있습니다.

연산자 적합성에 대한 이론적인 근거는 Swift 발전 제안(Swift evolution proposal) 에서 찾을 수 있습니다. Swift 콜렉션 프로토콜에 관해 더 배우고 자신만의 만드는 방법을 배우고(learn how to build your own) 싶어 할수 있습니다.

마지막으로, 새로운(new) 프로그래밍 패러다임과 마찬가지로, 열광하기 쉽고 모든 것(all the things)에서 사용합니다. 흥미로운 Chris Edihof가 작성한 블로그 게시글(blog post by Chris Eidhof)은 우리에게 묘책(silver bullet)을 조심해야 하고 그냥(just because) 어디에서나 프로토콜을 사용해야 한다는 것을 상기시켜 줍니다.





반응형
Posted by 까칠코더
,