프로토콜(Protocols)
프로토콜(Protocols)
Clojure는 추상화를 중심으로 쓰이는 언어예요. 시퀀스, 컬렉션, 호출 가능성(callability) 같은 다양한 추상화가 있고, 그 추상화를 구현한 구현체들도 여럿 제공돼요. 이런 추상화는 호스트의 인터페이스로, 구현은 호스트의 클래스로 규정되요. 언어를 부팅하는 데는 이 정도로도 충분했지만, 정작 Clojure 자신은 그런 수준의 추상화와 저수준 구현 수단을 갖지 못했어요. 프로토콜(protocols)과 데이터 타입(datatypes)은 호스트 플랫폼의 기능과 전혀 타협하지 않으면서 추상화와 데이터 구조 정의를 위한 강력하고 유연한 메커니즘을 추가해 준 기능이에요.
출처: Clojure 공식문서
본문
동기(Motivation)
프로토콜이 왜 필요한지부터 짚어볼게요. 프로토콜에는 몇 가지 동기가 있어요.
- 인터페이스의 대안으로, 고성능이면서 동적인 다형성(polymorphism) 구조를 제공해요
- 인터페이스의 좋은 점은 그대로 살려요 ** 명세만 있고 구현은 없어요 ** 하나의 타입이 여러 프로토콜을 구현할 수 있어요
- 동시에 일부 단점은 피해요 ** 어떤 인터페이스를 구현할지는 타입을 만드는 사람이 설계 시점에 정하는 일이라, 나중에 확장할 수 없어요 (인터페이스 주입이 언젠가 이 문제를 해결할 수도 있지만요) ** 인터페이스를 구현하면 isa/instanceof 타입 관계와 계층이 생겨요
- '표현 문제(expression problem)'를 피해요 — 타입의 집합, 프로토콜, 그리고 타입에 대한 프로토콜 구현을 서로 다른 주체가 독립적으로 확장할 수 있게 해 주거든요 ** 래퍼나 어댑터 없이 그렇게 해요
- 멀티메서드의 90% 사용 사례(타입에 대한 단일 디스패치)를 지원하면서도 더 높은 수준의 추상화/조직화를 제공해요
참고로 프로토콜은 Clojure 1.2에서 처음 도입됐어요.
기본(Basics)
프로토콜은 이름 붙은 메서드들과 그 시그니처의 집합이에요. defprotocol로 정의해요.
(defprotocol AProtocol
"AProtocol 추상화에 대한 doc string"
(bar [a b] "bar 문서")
(baz [a] [a b] [a b c] "baz 문서"))
여기서 확인할 점을 정리할게요.
- 구현은 제공되지 않아요
- 프로토콜과 함수 각각에 문서를 달 수 있어요
- 위 코드는 다형적 함수들의 집합과 프로토콜 객체를 만들어 내요 ** 모두 정의를 둘러싼 네임스페이스로 한정(namespace-qualified)돼요
- 만들어진 함수들은 첫 번째 인자의 타입에 디스패치해요. 그래서 인자가 최소 하나는 있어야 해요
- defprotocol은 동적이라 AOT 컴파일이 필요 없어요
- 참고: 프로토콜 함수에는 primitive 타입 힌트가 지원되지 않아요
defprotocol은 프로토콜과 같은 이름의 인터페이스를 자동으로 생성해요. 예를 들어 my.ns/Protocol 프로토콜이 있으면 my.ns.Protocol 인터페이스가 생기는 식이죠. 그 인터페이스에는 프로토콜 함수에 대응하는 메서드들이 있고, 프로토콜은 그 인터페이스의 인스턴스와 자동으로 동작해요.
다만 deftype, defrecord, reify는 프로토콜을 직접 지원하므로, 이 인터페이스를 굳이 쓸 필요는 없어요.
(defprotocol P
(foo [x])
(bar-me [x] [x y]))
(deftype Foo [a b c]
P
(foo [x] a)
(bar-me [x] b)
(bar-me [x y] (+ c y)))
(bar-me (Foo. 1 2 3) 42)
=> 45
(foo
(let [x 42]
(reify P
(foo [this] 17)
(bar-me [this] x)
(bar-me [this y] x))))
=> 17
프로토콜에 참여하고 싶은 Java 클라이언트는 프로토콜이 생성한 인터페이스를 구현하는 게 가장 효율적이에요.
내가 통제하지 못하는 클래스나 타입을 프로토콜에 참여시키려면 외부 구현(external implementations) 이 필요한데, 이건 extend 생성자(construct)로 제공해요.
(extend AType
AProtocol
{:foo an-existing-fn
:bar (fn [a b] ...)
:baz (fn ([a]...) ([a b] ...)...)}
BProtocol
{...}
...)
extend는 타입/클래스(아래 보듯 인터페이스도 가능해요) 하나와, 프로토콜 + 함수 맵(평가된) 쌍을 하나 이상 받아요.
- AType이 첫 번째 인자로 주어지면 프로토콜 메서드의 다형성이 제공된 함수들을 호출하도록 확장해요
- 함수 맵은 키워드화된 메서드 이름을 보통의 fn으로 매핑한 맵이에요 ** 이 덕분에 기존 함수와 맵을 쉽게 재사용할 수 있어요. 파생이나 합성(composition) 없이 코드 재사용/믹스인을 할 수 있죠
- 인터페이스에 프로토콜을 구현할 수도 있어요 ** 주로 호스트(예: Java)와의 상호운용을 위한 거예요 ** 다만 구현의 우연한 다중 상속(incidental multiple inheritance) 가능성이 생겨요 *** 클래스가 둘 이상의 인터페이스로부터 상속받을 수 있고, 그 인터페이스들이 모두 프로토콜을 구현한다면요 *** 한 인터페이스가 다른 인터페이스에서 파생된 경우 더 파생된 쪽이 쓰이고, 그렇지 않으면 어느 쪽이 쓰일지는 명시되지 않아요
- 구현 함수는 첫 번째 인자가 AType의 인스턴스(instanceof)라고 가정할 수 있어요
- nil에 프로토콜을 구현할 수도 있어요
- nil이 아닌 경우에 대한 기본 구현을 정의하려면 Object를 쓰면 돼요
프로토콜은 완전히 실체화(reified)돼 있고, extends?, extenders, satisfies?를 통해 리플렉션적인 기능을 지원해요.
편의 매크로인 extend-type과 extend-protocol도 있어요. 외부 정의를 인라인으로 제공할 때는 extend를 직접 쓰는 것보다 이 매크로들이 더 편리해요.
(extend-type MyType
Countable
(cnt [c] ...)
Foo
(bar [x y] ...)
(baz ([x] ...) ([x y zs] ...)))
;다음처럼 확장됩니다:
(extend MyType
Countable
{:cnt (fn [c] ...)}
Foo
{:baz (fn ([x] ...) ([x y zs] ...))
:bar (fn [x y] ...)})
확장 지침(Guidelines for extension)
프로토콜은 열린 시스템(open system)이라 아무 타입에나 확장할 수 있어요. 충돌을 줄이려면 이런 지침을 고려해 보세요.
- 프로토콜도 대상 타입도 내 것이 아니라면, 앱(공개 라이브러리가 아닌) 코드에서만 확장해야 하고, 어느 쪽 소유자에 의해 깨질 수 있다고 각오해야 해요
- 프로토콜을 내가 소유하고 있다면, 그 패키지의 일부로 일반적인 대상에 대한 기본 버전 몇 개를 제공할 수 있어요. 다만 그렇게 하는 것이 독단적이라는 점은 감수해야 해요
- 잠재적인 대상들의 라이브러리를 배포한다면, 그 대상들에 대한 공통 프로토콜 구현을 제공할 수 있어요. 역시 당신이 규정한다는 사실을 감수해야 하죠. 특히 Clojure 자체에 포함된 프로토콜을 확장할 때는 각별히 주의해야 해요
- 라이브러리 개발자라면, 프로토콜도 대상 타입도 소유하지 않았을 때는 확장하지 말아야 해요
이 주제에 대한 메일링 리스트 토론도 함께 보면 도움이 돼요.
메타데이터로 확장(Extend via metadata)
Clojure 1.10부터 프로토콜은 선택적으로 값별 메타데이터(per-value metadata)를 통해 확장되도록 설정할 수 있어요.
(defprotocol Component
:extend-via-metadata true
(start [component]))
:extend-via-metadata를 true로 두면, 값이 프로토콜을 확장할 수 있어요. 값에 메타데이터를 더하는데, 키는 완전히 한정된 프로토콜 함수 심볼이고 값은 함수 구현이에요. 프로토콜 구현은 먼저 직접 정의(defrecord, deftype, reify)를 확인하고, 그다음 메타데이터 정의를, 마지막으로 외부 확장(extend, extend-type, extend-protocol)을 확인해요.
(def component (with-meta {:name "db"} {`start (constantly "started")}))
(start component)
;;=> "started"
더 알아보기
- defprotocol — 프로토콜을 정의하는 매크로
- deftype — 프로토콜을 직접 구현하는 타입을 만드는 매크로
- defrecord — 맵 기반 레코드를 만드는 매크로
- reify — 익명 타입에서 프로토콜을 구현하는 매크로
- extend — 타입/클래스에 프로토콜을 외부 확장하는 함수
- extend-type / extend-protocol — 확장을 위한 편의 매크로
- satisfies? — 값이 프로토콜을 만족하는지 검사하는 함수
- Multimethods and Hierarchies — 프로토콜과 함께 또 다른 런타임 다형성 방식을 제공하는 멀티메서드