프로토콜
프로토콜 (Protocols)
동기 (Motivation)
Clojure는 추상화 단위로 작성돼요. 시퀀스, 컬렉션, 호출 가능성(callability) 등에 대한 추상화가 있어요. 또한 Clojure는 이러한 추상화의 많은 구현을 공급해요. 추상화는 호스트 인터페이스로, 구현은 호스트 클래스로 명시돼요. 이것이 언어를 부트스트랩하기엔 충분했지만, Clojure에는 유사한 추상화와 저수준 구현 기능이 부족하게 남았어요. 프로토콜과 데이터타입 기능은 호스트 플랫폼의 기능에 비해 어떤 타협도 없이 추상화와 데이터 구조 정의를 위한 강력하고 유연한 메커니즘을 추가해요.
프로토콜에는 여러 동기가 있어요:
- 인터페이스의 대안으로 고성능이며 동적인 다형성 구성소를 제공
- 인터페이스의 좋은 점을 지원 ** 명세만 있고 구현은 없음 ** 단일 타입이 여러 프로토콜을 구현할 수 있음
- 단점 일부는 회피 ** 어떤 인터페이스를 구현할지는 타입 작성자의 설계 시점 선택이며, 나중에 확장할 수 없음(인터페이스 주입이 언젠가 이것을 해결할 수도 있지만) ** 인터페이스 구현은 isa/instanceof 타입 관계와 계층을 만듦
- 타입 집합, 프로토콜, 그리고 타입에 대한 프로토콜의 구현을 서로 다른 당사자가 독립적으로 확장함으로써 '표현 문제(expression problem)'를 회피 ** 래퍼/어댑터 없이 그렇게 함
- 멀티메서드의 90% 경우(타입에 대한 단일 디스패치)를 지원하면서 더 높은 수준의 추상화/조직을 제공
[NOTE] 프로토콜은 Clojure 1.2에서 도입됐어요.
본문
기초 (Basics)
프로토콜은 defprotocol을 사용해 정의되는, 이름 붙은 메서드들의 집합과 그 시그니처예요:
(defprotocol AProtocol
"A doc string for AProtocol abstraction"
(bar [a b] "bar docs")
(baz [a] [a b] [a b c] "baz docs"))
- 구현은 제공되지 않아요
- 프로토콜과 함수에 대한 문서(docs)를 지정할 수 있어요
- 위 코드는 다형 함수의 집합과 프로토콜 객체를 산출해요 ** 전부 정의를 둘러싼 네임스페이스에 의해 한정(qualified)돼요
- 결과 함수들은 첫 번째 인자의 타입으로 디스패치하며, 따라서 최소한 하나의 인자를 가져야 해요
- defprotocol은 동적이며 AOT 컴파일이 필요 없어요
- 참고: 프로토콜 함수에는 원시 타입 힌트가 지원되지 않아요
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 클라이언트는 프로토콜이 생성한 인터페이스를 구현함으로써 가장 효율적으로 참여할 수 있어요.
(당신이 통제하지 않는 클래스나 타입이 프로토콜에 참여하길 원할 때 필요한) 프로토콜의 외부 구현은 extend 구성소를 사용해 제공할 수 있어요:
(extend AType
AProtocol
{:foo an-existing-fn
:bar (fn [a b] ...)
:baz (fn ([a]...) ([a b] ...)...)}
BProtocol
{...}
...)
extend는 타입/클래스(또는 인터페이스, 아래 참고)와, 하나 이상의 프로토콜 + 함수 맵(평가된) 쌍을 받아요.
- AType이 첫 번째 인자로 제공될 때 제공된 함수들을 호출하도록 프로토콜 메서드의 다형성을 확장해요
- 함수 맵은 키워드화된 메서드 이름을 보통의 fn들에 매핑한 맵이에요 ** 이는 파생(derivation)이나 합성(composition) 없이 코드 재사용/믹스인을 위해 기존 fn과 맵을 쉽게 재사용할 수 있게 해 줘요
- 인터페이스에 프로토콜을 구현할 수 있어요 ** 이는 주로 호스트(예: Java)와의 상호운용을 돕기 위한 것이에요 ** 그러나 부수적(side-effect)으로 구현의 다중 상속을 열어 줘요 *** 클래스가 둘 이상의 인터페이스(둘 다 프로토콜을 구현)로부터 상속할 수 있으므로 *** 한 인터페이스가 다른 인터페이스에서 파생되면 더 파생된 것이 사용되고, 그렇지 않으면 어떤 것이 사용될지는 지정되지 않아요.
- 구현 fn은 첫 번째 인자가 AType의 인스턴스라고 가정할 수 있어요
- _nil_에 프로토콜을 구현할 수 있어요
- 프로토콜의 (nil이 아닌 것에 대한) 기본 구현을 정의하려면 그냥 Object를 사용하면 돼요
프로토콜은 완전히 reify되며 extends?, extenders, satisfies?를 통해 반영적(reflective) 능력을 지원해요.
- 편의 매크로 extend-type과 extend-protocol에 주목하세요
- 외부 정의를 인라인으로 제공한다면, extend를 직접 쓰는 것보다 이들이 더 편리해요
(extend-type MyType
Countable
(cnt [c] ...)
Foo
(bar [x y] ...)
(baz ([x] ...) ([x y zs] ...)))
;expands into:
(extend MyType
Countable
{:cnt (fn [c] ...)}
Foo
{:baz (fn ([x] ...) ([x y zs] ...))
:bar (fn [x y] ...)})
확장 지침 (Guidelines for extension)
프로토콜은 개방형 시스템이고 어떤 타입으로든 확장 가능해요. 충돌을 최소화하려면 다음 지침을 고려하세요:
- 프로토콜이나 대상 타입을 소유하지 않았다면, 앱(공개 lib가 아닌) 코드에서만 확장하고, 어느 소유자에 의해 깨질지도 모른다고 예상하세요.
- 프로토콜을 소유한다면, 그것을 행하는 독재적 성격에 따르는 조건으로, 공통 대상들을 위한 몇 가지 기본 버전을 패키지의 일부로 제공할 수 있어요.
- 잠재적 대상의 lib를 배포한다면, 배포하는 사실에 따르는 조건으로, 그것들을 위한 공통 프로토콜의 구현을 제공할 수 있어요. Clojure 자체에 포함된 프로토콜을 확장할 때는 특별히 주의해야 해요.
- 라이브러리 개발자라면, 프로토콜도 대상도 소유하지 않는다면 확장하지 마세요.
이 메일링 리스트 토론도 참고하세요.
메타데이터를 통한 확장 (Extend via metadata)
Clojure 1.10부터 프로토콜은 선택적으로 값별 메타데이터를 통해 확장되도록 지정할 수 있어요:
(defprotocol Component
:extend-via-metadata true
(start [component]))
:extend-via-metadata가 true이면, 값은 키가 완전한 한정(fully-qualified) 프로토콜 함수 심볼이고 값이 함수 구현인 메타데이터를 추가함으로써 프로토콜을 확장할 수 있어요. 프로토콜 구현은 먼저 직접 정의(defrecord, deftype, reify)를 확인하고, 그다음 메타데이터 정의, 그다음 외부 확장(extend, extend-type, extend-protocol)을 확인해요.
(def component (with-meta {:name "db"} {`start (constantly "started")}))
(start component)
;;=> "started"