Clojure with Polymorphism: 멀티메서드(Multimethod) 사용하기
2022년 10월 20일
Clojure
📕 목차
개요
클로저에서는 다형성 (polymorphism)을 다루기 위한 다양한 방법을 제공한다. 이 글에서는 그 방법중 하나인 멀티메서드(Multimethod) 를 살펴보도록 하겠다.
- 멀티메서드는 하나의 변수 (이름)에 key-value 형태의 methodTable을 만들어 연관테이블을 기반으로 다형성을 제공한다.
defmulti
매크로를 사용해서 dispatch function을 구현한다.defmethod
매크로를 사용해서 methodTable에 함수를 추가한다.
defmulti
- defmulti는 다형성을 위해 dispatch function를 구현하기 위한
clojure.lang.MultiFn
인스턴스를 생성해주는 매크로이다. - 내부적으로
ReentrantReadWriteLock
락을 사용하는데 나중에 알아보자. 🤔 :default
를 default dispatch value로 사용한다.
(defmacro defmulti
"..."
[mm-name & options]
(let [...
options (if (map? (first options))
(next options)
options)
dispatch-fn (first options)
options (next options)
m (if docstring
(assoc m :doc docstring)
m)
...
mm-name (with-meta mm-name m)]
(let [options (apply hash-map options)
default (get options :default :default)
hierarchy (get options :hierarchy #'global-hierarchy)]
(check-valid-options options :default :hierarchy)
`(let [v# (def ~mm-name)]
(when-not (and (.hasRoot v#) (instance? clojure.lang.MultiFn (deref v#)))
(def ~mm-name
;; clojure.lang.MultiFn 클래스의 인스턴스를 생성한다.
(new clojure.lang.MultiFn ~(name mm-name) ~dispatch-fn ~default ~hierarchy)))))))
defmethod
- defmulti와 항상 함께다니는 짝꿍이다.
- defmethod는
multifn 인스턴스의 addMethod 함수를 호출하는
매크로이다.
(defmacro defmethod
"Creates and installs a new method of multimethod associated with dispatch-value. "
{:added "1.0"}
[multifn dispatch-val & fn-tail]
`(. ~(with-meta multifn {:tag 'clojure.lang.MultiFn}) addMethod ~dispatch-val (fn ~@fn-tail)))
- multifn 인스턴스에 tag 메타정보와 함께 addMethod 함수를 호출한다.
- 여기서 addMethod를 호출할 때 사용한 방법은
instance-expr
인지Classname-symbol
인지 잘 모르겠다. 🤔 - with-meta에 대해서 공부해보자.
- 자세한 내용은 공식문서를 참고하자.
- 여기서 addMethod를 호출할 때 사용한 방법은
defmethod에서 호출되는 addMethod의 코드를 한번 살펴보자.
public MultiFn addMethod(Object dispatchVal, IFn method) {
rw.writeLock().lock();
try{
methodTable = getMethodTable().assoc(dispatchVal, method);
resetCache();
return this;
}
finally {
rw.writeLock().unlock();
}
}
- addMethod는 위와 같이 작성되어 있는데 dispatchVal를 키로 method를 값으로 해서 methodTable(PersistentHashMap)에 저장한다.
- 인자로 받는 method는 IFn 타입인 것을 확인할 수 있다.
동작방식
위에서 멀티메서드를 위한 defmulti
와 defmethod
의 코드를 보면서 각각의 동작에 대해서 알아 보았다. 이번에는 이 두개의 함수가 어떻게 서로 연관되어 동작하는지 알아보겠다.
실제로 정의된 multimethod를 호출하는 것은 두 개의 함수를 평가하는 것을 수반한다.
- dispatch function 함수를 실행해서 결과 값을 받는다.
- 위의 결과 값으로 methodTable에서 찾아서 해당 함수를 실행시킨다.
(defmulti coffee
(fn [coffee]
coffee))
(defmethod coffee :아메리카노
[coffee]
(str (name coffee) " 냠냠"))
(defmethod coffee :카페라떼
[coffee]
(str (name coffee) " 맛있당"))
(coffee :아메리카노)
=> 아메리카노 냠냠
(coffee :카페라떼)
=> 카페라떼 맛있당
위의 예시에서 우리는 coffee라는 심볼의 멀티메서드를 정의했다. 그리고 :아메리카노
, :카페라떼
두 개의 메서드를 추가했다.
여기서 (coffee :아메리카노)
를 실행할 때 다음과 같은 과정이 일어난다.
-
coffee는 MultiFn 클래스의 인스턴스이고 해당 인스턴스의 invoke 함수가 호출된다.
- invoke 함수는 내부적으로 getFn 함수를 호출한다.
public Object invoke(Object arg1) { // defmulti에서 작성한 dispatchFn을 호출한다. // 그 결과값을 getFn 함수에 전달한다. return getFn(dispatchFn.invoke(arg1)).invoke(Util.ret1(arg1,arg1=null)); }
-
getFn 함수는 내부적으로 getMethod 함수를 실행해서 targetFn을 반환한다.
- 반환되는 targetFn의 타입은 IFn 인터페이스이다.
private IFn getFn(Object dispatchVal) { IFn targetFn = **getMethod(dispatchVal);** if(targetFn == null) throw new IllegalArgumentException(String.format("No method in multimethod '%s' for dispatch value: %s", name, dispatchVal)); return targetFn; }
-
위의 getMethod 의 동작을 살펴보자.
public IFn getMethod(Object dispatchVal) { if(cachedHierarchy != hierarchy.deref()) resetCache(); IFn targetFn = (IFn) methodCache.valAt(dispatchVal); if(targetFn != null) return targetFn; return **findAndCacheBestMethod(dispatchVal);** }
- methodCache로부터 dispatchVal을 사용해서 targetFn을 조회하고 찾지 못하면
findAndCacheBestMethod
메서드를 호출한다.
- methodCache로부터 dispatchVal을 사용해서 targetFn을 조회하고 찾지 못하면
-
findAndCacheBestMethod 메서드는 다음과 같다.
private IFn findAndCacheBestMethod(Object dispatchVal) { rw.readLock().lock(); Object bestValue; IPersistentMap mt = methodTable; IPersistentMap pt = preferTable; Object ch = cachedHierarchy; try { Map.Entry bestEntry = null; // getMethodTable로부터 엔트리를 가져온다. for(Object o : getMethodTable()) { Map.Entry e = (Map.Entry) o; if(isA(ch, dispatchVal, e.getKey())) { if(bestEntry == null || dominates(ch, e.getKey(), bestEntry.getKey())) bestEntry = e; if(!dominates(ch, bestEntry.getKey(), e.getKey())) throw new IllegalArgumentException( String.format( "Multiple methods in multimethod '%s' match dispatch value: %s -> %s and %s, and neither is preferred", name, dispatchVal, e.getKey(), bestEntry.getKey())); } } if(bestEntry == null) { bestValue = methodTable.valAt(defaultDispatchVal); if(bestValue == null) return null; } else bestValue = bestEntry.getValue(); } finally { rw.readLock().unlock(); } //ensure basis has stayed stable throughout, else redo rw.writeLock().lock(); try { if( mt == methodTable && pt == preferTable && ch == cachedHierarchy && cachedHierarchy == hierarchy.deref()) { //place in cache // 여기서 캐시를 해준다. methodCache = methodCache.assoc(dispatchVal, bestValue); **return (IFn) bestValue;** } else { resetCache(); return findAndCacheBestMethod(dispatchVal); } } finally { rw.writeLock().unlock(); } }
- 결국 dispatch 값에 의해 선택된 메서드를 찾아서 반환한다.
- 그리고 methodTable에서 찾은 메서드를 호출한다.
getFn(dispatchFn.invoke(arg1)).invoke(Util.ret1(arg1,arg1=null));
- getFn으로 찾아온 것은 :아메리카노에 해당하는 메서드이고
(str (name coffee) " 냠냠")
이 실행되어 “아메리카노 냠냠” 이 반환한다.
요약
- dispatch function 평가 후 methodTable에서 dispatch value와 일치하는 메서드 찾은 후 → 찾은 메서드 실행하는 2단계로 동작한다.
- 멀티메서드는 내부적으로 한번 찾고나면 methodCache에 캐싱해 O(1)로 메서드를 찾아서 실행할 수 있다.
참고자료
- https://insideclojure.org/2014/12/13/multimethod-default/
- https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/MultiFn.java
- https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/PersistentHashMap.java
- https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/IFn.java
- https://clojure.org/reference/java_interop#_the_dot_special_form