map과 map

Clojure에서 map은 두 가지 의미를 갖는다.

  • 함수 map: list에 함수 f를 적용하는 함수.
  • 자료구조 map: #{:name "John"}. Hash Map.

둘을 구분하기 위해 이 문서에서는 자료구조로서의 map을 Hash Map, 해시맵이라 부른다.

함수 map

defn map

(defn map
  "Returns a lazy sequence consisting of the result of applying f to
  the set of first items of each coll, followed by applying f to the
  set of second items in each coll, until any one of the colls is
  exhausted.  Any remaining items in other colls are ignored. Function
  f should accept number-of-colls arguments. Returns a transducer when
  no collection is provided."
  {:added "1.0"
   :static true}
  ([f]
    (fn [rf]
      (fn
        ([] (rf))
        ([result] (rf result))
        ([result input]
           (rf result (f input)))
        ([result input & inputs]
           (rf result (apply f input inputs))))))
  ([f coll]
   (lazy-seq
    (when-let [s (seq coll)]
      (if (chunked-seq? s)
        (let [c (chunk-first s)
              size (int (count c))
              b (chunk-buffer size)]
          (dotimes [i size]
              (chunk-append b (f (.nth c i))))
          (chunk-cons (chunk b) (map f (chunk-rest s))))
        (cons (f (first s)) (map f (rest s)))))))
  ([f c1 c2]
   (lazy-seq
    (let [s1 (seq c1) s2 (seq c2)]
      (when (and s1 s2)
        (cons (f (first s1) (first s2))
              (map f (rest s1) (rest s2)))))))
  ([f c1 c2 c3]
   (lazy-seq
    (let [s1 (seq c1) s2 (seq c2) s3 (seq c3)]
      (when (and  s1 s2 s3)
        (cons (f (first s1) (first s2) (first s3))
              (map f (rest s1) (rest s2) (rest s3)))))))
  ([f c1 c2 c3 & colls]
   (let [step (fn step [cs]
                 (lazy-seq
                  (let [ss (map seq cs)]
                    (when (every? identity ss)
                      (cons (map first ss) (step (map rest ss)))))))]
     (map #(apply f %) (step (conj colls c3 c2 c1))))))

Examples

map 함수의 기본적인 사용은 리스트에 적용할 함수를 제공하는 것이다.

(map inc [1 2 3])   ; (2 3 4)
(map inc '(1 2 3))  ; (2 3 4)
(map even? [1 2 3]) ; (false true false)
(map str [1 2 3])   ; ("1" "2" "3")

; 익명 함수
(map #(* 2 %) [1 2 3]) ; (2 4 6)

그렇다고 리스트에만 사용할 수 있는 건 아니다.

; 집합에 적용
(map inc #{1 2 3}) ; (2 4 3)

hash map에 적용할 때에는 hash map의 각 MapEntry에 적용된다.

; hash map 에 적용
(map (fn [entry] (type entry))
     {:name "John" :age 28})
=> (clojure.lang.MapEntry clojure.lang.MapEntry)

MapEntry는 vector처럼 사용할 수 있다.

(map (fn [entry] entry)
     {:name "John" :age 28})
=> ([:name "John"] [:age 28])

(map (fn [entry] (str (first entry) "->" (second entry)))
     {:name "John" :age 28})
=> (":name->John" ":age->28")

; entry를 구조분해하여 key 와 value 를 사용
(map (fn [[key value]] (str key "->" value))
     {:name "John" :age 28})
=> (":name->John" ":age->28")

함수처럼 사용할 수 있는 자료구조를 적용하는 것도 가능하다.

; hash set을 리스트에 적용
(map #{1 3 5}
     [1 2 3 4 5])
=> (1 nil 3 nil 5)

; hash map을 리스트에 적용
(map {1 "일" 3 "삼" 5 "오"}
     [1 2 3 4 5])
=> ("일" nil "삼" nil "오")

; 키워드를 hash map을 아이템으로 삼는 리스트에 적용
(map :name
     [{:age 10 :name "John"} {:age 11 :name "Jack"}])
=> ("John" "Jack")

여러 개의 리스트에 적용하면, 리스트의 각 아이템 순서쌍에 적용한다.

(map +
     [1 2 3]
     [100 200 300])
=> (101 202 303)

(map #(str %1 "," %2)
     [1 2 3]
     [100 200 300])
=> ("1,100" "2,200" "3,300")

(map #(str %1 "," %2 "," %3)
     [1 2 3]
     [100 200 300]
     [:a :b :c])
=> ("1,100,:a" "2,200,:b" "3,300,:c")

자료구조 map

Examples

{key value key value ...} 형태로 선언할 수 있다.

{:a 1 :b 2 :c 3}

Java나 Javascript 같은 다른 언어에 익숙하다면 key 값으로 문자열을 사용하는 것이 익숙하겠지만, Clojure에서는 hash map의 key로 Keyword를 주로 사용한다.

; 문자열을 key 로 사용하는 경우
{"a" 1 "b" 2 "c" 3}

; Clojure의 Keyword를 key로 사용하는 경우(권장)
{:a 1 :b 2 :c 3}

hash map에서 값을 꺼내는 기본적인 방법은 3가지.

  • hash map을 함수로 사용하고, key를 인자로 제공하는 방법.
  • Keyword를 함수로 사용하고, hash map을 인자로 제공하는 방법. (권장)
  • get 함수를 사용하는 방법.
    • get 함수는 찾는 값이 없을 경우의 대안도 정의할 수 있다.
; hash map을 함수로 사용하는 경우
({:a 1 :b 2 :c 3} :b) ; 2

; Keyword를 함수로 사용하는 경우
(:b {:a 1 :b 2 :c 3}) ; 2

; 그냥 get 함수를 사용하는 경우
(get {:a 1 :b 2 :c 3} :b) ; 2

; get을 쓰면 찾는 값이 없는 경우의 대안을 제공할 수 있다
(get {:a 1 :b 2 :c 3} :d 777)
=> 777

assoc, dissoc의 사용.

(assoc {:a 1 :b 2 :c 3} :b 1000) ; {:a 1, :b 1000, :c 3}
(assoc {:a 1 :b 2 :c 3} :d 888)  ; {:a 1, :b 2, :c 3, :d 888}

(dissoc {:a 1 :b 2 :c 3} :a)     ; {:b 2, :c 3}

merge를 쓰면 두 hash map을 합친 hash map을 얻을 수 있다. 단 중복 key가 있다면 나중의 hash map의 것으로 적용된다.

(merge {:a 1 :b 2 :c 3} {:a 100 :d 400})
=> {:a 100, :b 2, :c 3, :d 400}

IPersistentMap

Clojure의 RunTime을 담당하는 RT.javamap함수를 읽어보면 인자의 수에 따라 PersistentArrayMapPersistentHashMap을 선택적으로 생성하고 있음을 알 수 있다.

clojure.lang.RT::map

static public IPersistentMap map(Object... init){
    if(init == null || init.length == 0)
        return PersistentArrayMap.EMPTY;
    else if(init.length <= PersistentArrayMap.HASHTABLE_THRESHOLD)
        return PersistentArrayMap.createWithCheck(init);
    return PersistentHashMap.createWithCheck(init);
}

HASHTABLE_THRESHOLD는 상수로, 16이다.

clojure.lang.PersistentArrayMap::HASHTABLE_THRESHOLD

static final int HASHTABLE_THRESHOLD = 16;

이걸 검증해보는 건 쉬운 일이다.

(type {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8})
=> clojure.lang.PersistentArrayMap

(type {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8 :i 9})
=> clojure.lang.PersistentHashMap

16개의 인자를 제공하여 8개의 엔트리를 가진 map을 생성해 보았더니 타입이 PersistentArrayMap이었다.

그리고 인자를 두 개 더 추가해 9개의 엔트리를 가진 map을 생성했더니 타입이 PersistentHashMap이었다.

PersistentArrayMap

IPersistentMap의 구현체 중 하나인 PersistentArrayMap을 살펴보자.

위의 map 함수에서 보았듯이, 인자의 수가 16개 이하이면 PersistentArrayMap이 생성된다.

생성과 구조

이 생성 과정을 더 자세히 살펴보자.

clojure.lang.PersistentArrayMap::createWithCheck

static public PersistentArrayMap createWithCheck(Object[] init){
    for(int i=0;i< init.length;i += 2)
        {
        for(int j=i+2;j<init.length;j += 2)
            {
            // 모든 짝수 인덱스 아이템(key)의 중복을 검사한다.
            if(equalKey(init[i],init[j]))
                throw new IllegalArgumentException("Duplicate key: " + init[i]);
            }
        }
    // key 중복이 없다면 PersistentArrayMap을 생성한다.
    return new PersistentArrayMap(init);
}

createWithCheck은 주어진 인자 배열의 짝수 인덱스 아이템(key)에 대하여 equalKey 검사를 하고 있다. value는 중복이어도 상관없으니 따로 검사하지 않는다.

key 중복이 없다면 PersistentArrayMap의 생성자를 호출한다. 이 생성자는 매우 심플한 구조로 되어 있다.

clojure.lang.PersistentArrayMap

public PersistentArrayMap(Object[] init) {
    this.array = init;
    this._meta = null;
}

그리고 this.array는 다음과 같이 key와 value를 번갈아가며 보관하는 구조로 되어 있다.

{:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8}

array map의 구조

배열 기반의 연산

즉, PersistentArrayMap은 주어진 배열을 감싸고 있는 Map 인터페이스의 구현체이다.

따라서 몇몇 주요 연산은 배열을 순회하거나, 배열의 길이를 이용한다.

PersistentArrayMap은 키 값을 8개까지만 포함하며 키가 더 추가되면 PersistentHashMap 타입을 생성하게 되는데, 아마도 8개까지는 HashMap보다 ArrayMap의 퍼포먼스가 더 낫기 때문에 이렇게 결정한 것으로 예상한다.

count

다음은 count인데, 배열의 길이를 2로 나누는 것이 전부이다.

clojure.lang.PersistentArrayMap::count

public int count() {
    return this.array.length / 2;
}
containsKey

containsKey는 단순하게 indexOf를 사용한다.

clojure.lang.PersistentArrayMap::containsKey

public boolean containsKey(Object key) {
    return this.indexOf(key) >= 0;
}

containsKey가 사용하고 있는 indexOf는 배열의 짝수 인덱스를 순회하며 주어진 키와 같은 키를 찾는다.

private int indexOf(Object key) {
    if (key instanceof Keyword) {
        // key의 타입이 keyword라면...
        for(int i = 0; i < this.array.length; i += 2) {
            // 짝수 인덱스(key)를 순회하며 비교한다
            if (key == this.array[i]) {
                return i;
            }
        }
        return -1;  // sentinel value로 -1 을 사용한다.
    } else {
        // key의 타입이 keyword가 아니라면...
        return this.indexOfObject(key);
    }
}
assoc, dissoc

assoc은 array copy를 한 다음 길이에 따라 PersistentArrayMap 또는 PersistentHashMap을 생성하는 방식으로 작동한다.

clojure.lang.PersistentArrayMap::assoc

public IPersistentMap assoc(Object key, Object val) {
    // 키의 인덱스를 찾는다.
    int i = this.indexOf(key);
    Object[] newArray;
    if (i >= 0) {
        // 해당 키가 배열에 존재한다면...
        if (this.array[i + 1] == val) {
            return this;
        }
        // 배열을 복사해서 새로운 배열을 만들고,
        // 복사한 배열에 assoc할 키/값을 해당 위치에 입력한다.
        newArray = (Object[])this.array.clone();
        newArray[i + 1] = val;
    } else {
        // 해당 키가 배열에 존재하지 않는다면...
        if (this.array.length >= 16) {
            // 배열의 길이가 16 이상이라면 8개 엔트리를 초과하므로
            // PersistentHashMap 을 생성한다.
            return this.createHT(this.array).assoc(key, val);
        }

        // 새로운 키와 값이 들어가야 하므로
        // +2 길이를 갖는 배열을 새로 만들고, array copy를 한다.
        newArray = new Object[this.array.length + 2];
        if (this.array.length > 0) {
            System.arraycopy(this.array, 0, newArray, 0, this.array.length);
        }
        // assoc할 키/값을 늘어난 위치에 입력한다.
        newArray[newArray.length - 2] = key;
        newArray[newArray.length - 1] = val;
    }
    // 새로 만든 배열을 사용해 새로운 PersistentArrayMap을 생성해 리턴한다.
    return this.create(newArray);
}

PersistentArrayMapdissoc을 갖고 있지 않은데, 실제 dissoc 함수를 사용할 때 호출되는 것이 without이기 때문이다.

RTdissoc 코드를 읽어보면 이를 확인할 수 있다.

clojure.lang.RT::dissoc

static public Object dissoc(Object coll, Object key) {
    if(coll == null)
        return null;
    return ((IPersistentMap) coll).without(key); // coll의 without을 부른다
}

이제 without의 코드를 읽어보자.

clojure.lang.PersistentArrayMap::without

public IPersistentMap without(Object key){
    // dissoc 하려는 키의 인덱스를 찾는다
    int i = indexOf(key);
    if(i >= 0) //have key, will remove
        {
        int newlen = array.length - 2;
        if(newlen == 0)
            return empty();
        // 새로운 배열을 만들고
        Object[] newArray = new Object[newlen];
        // dissoc 하려는 키 인덱스의 왼쪽 배열 array copy
        System.arraycopy(array, 0, newArray, 0, i);
        // dissoc 하려는 키 인덱스의 오른쪽 배열 array copy
        System.arraycopy(array, i+2, newArray, i, newlen - i);

        // 새로운 PersistentArrayMap 생성해 리턴
        return create(newArray);
        }
    // 키를 못 찾았으면 아무것도 하지 않고 그대로 this 리턴
    return this;
}

PersistentHashMap

엔트리의 수가 8개를 초과한다면 PersistentHashMap을 생성하게 된다.

clojure.lang.PersistentHashMap::create

public static PersistentHashMap create(Object... init){
    ITransientMap ret = EMPTY.asTransient();
    for(int i = 0; i < init.length; i += 2)
        {
        ret = ret.assoc(init[i], init[i + 1]);
        }
    return (PersistentHashMap) ret.persistent();
}

코드를 읽어보면 PersistentHashMapITransientMap을 임시로 생성해 배열의 각 아이템을 map에 등록하고 나서, 등록이 완료되면 persistent()를 호출해 불변 객체로 만든다는 것을 알 수 있다.

TransientHashMapPersistentHashMap의 내부 클래스이며, PersistentHashMap의 생성에 사용되는 특수한 구현이다.

(Clojure의 transient 개념에 대해서는 Clojure Transient Data Structures 문서 참고.)

시험삼아 엔트리 9개를 갖는 PersistentHashMap을 생성해 보았더니 다음과 같은 구조를 갖고 있었다.

9개의 아이템이 있는 PersistentHashMap

엔트리 기준으로 요약하자면 다음과 같은 것이다.

  • array
    • entry 0
    • array
      • entry 3
      • entry 2
    • entry 4
    • entry 1
    • entry 8
    • entry 5
    • entry 7
    • entry 6

활용

submap?

map과 map의 포함관계를 확인해야 할 때가 있다.

나는 처음에 이런 형태를 생각했다.

(defn submap?
  "m1이 m2의 subset이면 true를 리턴합니다."
  [m1 m2]
  (= m1 (select-keys m2 (keys m1))))

다음은 namenu 님이 알려주신 코드로, 재귀하는 구조를 갖고 있다.

clojure.tools.deps.alpha.script.test-make-classpath2 submap?

(defn submap?
  "Is m1 a subset of m2?"
  [m1 m2]
  (if (and (map? m1) (map? m2))
    (every? (fn [[k v]] (and (contains? m2 k)
                          (submap? v (get m2 k)))) ; 재귀
      m1)
    (= m1 m2)))

stackoverflow.com의 질문 How to check if a map is a subset of another in clojure?이 읽어볼 만하다.

위 글에 나오는 clojure.set/subset?을 사용하는 방법이 무척 흥미롭고 재미있다. 나는 이 방법이 마음에 든다.

(defn submap?
  "m1이 m2의 submap이면 true를 리턴합니다."
  [m1 m2]
  (clojure.set/subset? (set m1) (set m2)))

함께 읽기

참고문헌