데이터 구조 (Data Structures)

데이터 구조 (Data Structures)

Clojure에는 풍부한 데이터 구조가 있어요. 모두 불변(immutable)·영속(persistent) 이라는 공통 속성을 공유해요. 공식 Reference 기준으로 기본 값 타입과 리스트·벡터·맵·셋 컬렉션을 정리할게요.

출처: Clojure Reference - Data Structures

본문

Clojure의 데이터 구조는 다음 속성을 함께 갖고 있어요.

  • 불변이다
  • 리더로 다시 읽을 수(read-able) 있다
  • equals 구현에서 올바른 값 동등성 시맨틱을 지원한다
  • 좋은 해시 값을 제공한다

그리고 컬렉션은 추가로:

  • 인터페이스로 다뤄진다
  • 시퀀싱(sequencing)을 지원한다
  • 영속적(persistent) 조작을 지원한다
  • 메타데이터를 지원한다
  • java.lang.Iterable을 구현한다
  • java.util.Collection 또는 java.util.Map의 필수(읽기 전용) 부분을 구현한다

nil

nil은 Clojure의 모든 데이터 타입에서 가질 수 있는 값이에요. Java의 null과 같은 값이고, 조건 시스템은 nilfalse를 기준으로 해요 — 조건 테스트에서 논리 거짓은 nilfalse이고, 그 외는 전부 논리 참이에요. 또 nil은 시퀀스 프로토콜에서 시퀀스 끝맺음 센티널 값으로도 쓰여요.

숫자 (Numbers)

Clojure는 기본적으로 JVM 원시 값(primitive)을 온전히 지원해 숫자 애플리케이션에서 고성능·관용적 코드를 짤 수 있어요. java.lang.Number에서 파생된 박싱 숫자 타입(BigInteger, BigDecimal)과 자체 Ratio 타입도 지원해요.

  • Long — 기본적으로 자연수는 Java long 원시 타입 인스턴스로 동작해요. 원시 정수 연산이 원시 값에 담기엔 너무 큰 값을 내면 java.lang.ArithmeticException이 던져져요. 아포스트로피가 붙은 연산자 +' -' *' inc' dec'는 오버플로 시 자동으로 BigInt로 승격하지만 일반 연산자보다는 비효율적이에요.
  • Ratio — 정수 사이의 비율을 나타내요. 정수로 환원되지 않는 정수 나눗셈은 부동소수점이나 잘린 값이 아니라 비율로 나와요. 즉 22/7 = 22/7이에요.
  • 전염(Contagion) — BigInt와 부동소수점 타입은 연산을 가로질러 "전염"돼요. BigInt가 포함된 정수 연산은 BigInt 결과를, double이나 float가 포함된 연산은 double 결과를 내요.
  • BigInt/BigDecimal 리터럴 — 뒤에 각각 NM 접미사를 붙여요.
예시식 반환값
(== 1 1.0 1M) true
(/ 2 3) 2/3
(/ 2.0 3) 0.6666666666666666
(map #(Math/abs %) (range -3 3)) (3 2 1 0 1 2)
(class 36786883868216818816N) clojure.lang.BigInt
(class 3.14159265358M) java.math.BigDecimal

관련 함수 — 계산: + - * / inc dec quot rem min max / 자동 승격 계산: +' -' *' inc' dec' / 비교: == < > >= zero? pos? neg? / 비트 연산: bit-and bit-or bit-xor bit-not bit-shift-right bit-shift-left / Ratio: numerator denominator / 변환: int bigdec bigint double float long num short

문자열 (Strings)

Clojure 문자열은 Java String이에요.

user=> (map (fn [x] (.toUpperCase x)) (.split "Dasher Dancer Prancer" " "))
("DASHER" "DANCER" "PRANCER")

관련 함수 — str string? pr-str prn-str print-str println-str with-out-str 문자 관련 — char char-name-string char-escape-string

키워드 (Keywords)

키워드는 자기 자신으로 평가되는 상징적 식별자예요. 매우 빠른 동등성 테스트를 제공해요. 심볼과 마찬가지로 이름과 선택적 네임스페이스를 갖고, 둘 다 문자열이에요. 앞의 :는 네임스페이스나 이름에 포함되지 않아요. 키워드는 한 인자(맵)와 선택적 두 번째 인자(기본값)를 받는 IFn을 구현해요. (:mykey my-hash-map :none)(get my-hash-map :mykey :none)과 같아요.

관련 함수 — keyword keyword?

심볼 (Symbols)

심볼은 보통 다른 무언가를 가리키는 데 쓰는 식별자예요. 프로그램 폼에서 함수 파라미터, let 바인딩, 클래스 이름, 클래스 멤버, 전역 var를 가리키는 데 쓰여요. 이름과 선택적 네임스페이스를 갖고 문자열이에요. 메타데이터를 가질 수 있어요(with-meta). 키워드와 마찬가지로 한 인자(맵)와 선택적 기본값 인자를 받는 IFn을 구현해요.

관련 함수 — symbol symbol? gensym (#- 접미사 리더 매크로 참고)

컬렉션 (Collections)

Clojure 컬렉션은 전부 불변이고 영속적이에요. 특히 구조 공유(structural sharing) 를 활용해 '변형된' 버전을 효율적으로 만들고, 영속적 사용에 대한 성능 보장을 지켜요. 컬렉션은 효율적이고 본질적으로 스레드 안전해요. 컬렉션은 추상화로 표현되고 하나 이상의 구체적 실현(realization)이 있을 수 있어요. '변형' 연산이 새 컬렉션을 내므로 원본과 다른 구체 타입일 수 있지만, 같은 논리적(인터페이스) 타입이에요.

모든 컬렉션은 크기를 얻는 count, '추가'하는 conj, 전체를 훑는 시퀀스를 얻는 seq를 지원하며, 컬렉션 타입에 따라 세부 동작이 조금씩 달라요. 컬렉션이 seq를 지원하므로 모든 시퀀스 함수를 어떤 컬렉션에도 쓸 수 있어요.

해시 — Clojure 컬렉션은 hashCode() 구현에서 Java 컬렉션 인터페이스가 지정한 알고리즘을 따르고, 더 나은 해시 속성을 주는 자체 hasheq 값도 계산해요. 순서 있는 컬렉션(벡터·리스트·seq 등)과 무순서 컬렉션(맵·셋)은 각각 다음 알고리즘으로 hasheq를 계산해요 (hash는 hasheq를 계산).

(defn hash-ordered [collection]
  (-> (reduce (fn [acc e] (unchecked-add-int
                           (unchecked-multiply-int 31 acc)
                           (hash e)))
              1
              collection)
      (mix-collection-hash (count collection))))
(defn hash-unordered [collection]
  (-> (reduce unchecked-add-int 0 (map hash collection))
      (mix-collection-hash (count collection))))

mix-collection-hash 알고리즘은 변경될 수 있는 구현 세부 사항이에요.

리스트 (Lists, IPersistentList)

리스트는 컬렉션이고 ISeq 인터페이스를 직접 구현해요. (빈 리스트도 ISeq를 구현하지만, seq 함수는 빈 시퀀스에 대해 항상 nil을 돌려줘요.) count는 O(1)이고, conj는 항목을 리스트 맨 앞에 넣어요.

관련 함수 — 만들기: list list* / 스택처럼 다루기: peek pop / 확인: list?

벡터 (Vectors, IPersistentVector)

벡터는 연속된 정수로 인덱스되는 값들의 컬렉션이에요. 인덱스로 항목에 log32N 홉으로 접근할 수 있어요. count는 O(1)이고, conj는 항목을 벡터 끝에 넣어요. 역순 항목을 돌려주는 rseq도 지원해요. 벡터는 한 인자를 받는 IFn을 구현해요 — 그 인자를 인덱스로 간주하고 nth처럼 자기 자신에서 찾아요. 즉 벡터는 자기 인덱스의 함수예요. 벡터 비교는 먼저 길이, 그다음 각 요소를 순서대로 비교해요.

관련 함수 — 만들기: vector vec vector-of / 확인: get nth peek rseq vector? / '변경': assoc pop subvec replace (zippers도 참고)

맵 (Maps, IPersistentMap)

맵은 키를 값에 매핑하는 컬렉션이에요. 해시 맵과 정렬 맵 두 종류가 제공돼요. 해시 맵은 키가 hashCodeequals를 올바르게 지원해야 하고, 정렬 맵은 키가 Comparable을 구현하거나 Comparator 인스턴스여야 해요. 해시 맵이 더 빠른 접근(log32N 홉)을 제공하지만, 정렬 맵은 말 그대로 정렬돼 있어요. count는 O(1)이에요. conj는 (단일 엔트리일 수도 있는) 다른 맵을 항목으로 받아, 기존 맵에 새 맵의 엔트리를 더한 새 맵을 돌려줘요 (기존 엔트리를 덮어쓸 수 있음). MapEntry나 두 항목(키·값)의 벡터도 받아요. seq는 키·값 쌍인 맵 엔트리들의 시퀀스를 돌려줘요. 정렬 맵은 역순 엔트리를 주는 rseq도 지원해요. 맵은 한 인자(키)와 선택적 기본값 인자를 받는 IFn을 구현해요 — 즉 맵은 자기 키의 함수예요. nil 키와 값은 괜찮아요.

관련 함수 — 만들기: hash-map sorted-map sorted-map-by / '변경': assoc dissoc select-keys merge merge-with zipmap / 확인: get contains? find keys vals map? / 엔트리 확인: key val

StructMaps

StructMap 대부분의 용도는 이제 레코드(records) 로 쓰는 게 좋아요. 여러 맵 인스턴스가 같은 기본 키 집합을 가질 때(다른 언어에서 struct나 object로 쓰이는 상황), StructMap은 그 키 정보를 효율적으로 공유하면서 선택적인 고성능 접근자를 제공해요. StructMap은 모든 면에서 맵이라 같은 함수 집합을 지원하고 다른 맵과 상호운용되며 영속적으로 확장 가능해요(기본 키에 제한되지 않음). 유일한 제약은 기본 키 중 하나를 dissoc할 수 없다는 것뿐이고, 기본 키는 순서를 유지해요. create-structdefstruct로 구조 기반 객체를 만든 뒤 struct-map이나 struct로 인스턴스를 만들어요.

(defstruct desilu :fred :ricky)
(def x (map (fn [n]
              (struct-map desilu
                :fred n
                :ricky 2
                :lucy 3
                :ethel 4))
            (range 100000)))
(def fred (accessor desilu :fred))
(reduce (fn [n y] (+ n (:fred y))) 0 x)
 -> 4999950000
(reduce (fn [n y] (+ n (fred y))) 0 x)
 -> 4999950000

관련 함수 — 설정: create-struct defstruct accessor / 인스턴스 생성: struct-map struct

ArrayMaps

코드 폼을 조작할 때 키 순서를 유지하는 맵이 필요할 때가 있어요. array map은 그런 맵으로, key val key val...의 배열로 구현돼요. 그래서 선형 탐색 성능을 가지며 매우 작은 맵에만 적합해요. 전체 맵 인터페이스를 구현하고 array-map으로 만들 수 있어요. array map은 '변형'되지 않았을 때만 정렬 순서를 유지하고, 이후 assoc하면 결국 해시 맵으로 '변해요'.

셋 (Sets)

셋은 고유 값들의 컬렉션이에요.

#{:a :b :c :d}
-> #{:d :a :b :c}

hash-setsorted-set 함수로 셋을 만들고, set 함수로 컬렉션의 값 집합을 얻을 수 있어요.

(hash-set :a :b :c :d)
-> #{:d :a :b :c}

(sorted-set :a :b :c :d)
-> #{:a :b :c :d}

(set [1 2 3 2 1 2 3])
-> #{1 2 3}

셋은 컬렉션이에요.

(def s #{:a :b :c :d})
(conj s :e)
-> #{:d :a :b :e :c}

(count s)
-> 4

(seq s)
-> (:d :a :b :c)

(= (conj s :e) #{:a :b :c :d :e})
-> true

셋은 disj로 '제거'를, contains?get을 지원하며, get은 발견하면 키와 동등하게 비교되는 객체를 돌려줘요. 셋은 get을 통한 멤버의 함수이기도 해요.

(disj s :d)
-> #{:a :b :c}

(contains? s :b)
-> true

(get s :a)
-> :a

(s :b)
-> :b

(s :k)
-> nil

Clojure는 union / difference / intersection 같은 기본 셋 연산과, '관계'(단순히 맵들의 셋)에 대한 의사 관계대수 연산 select / index / rename / join을 제공해요.

더 알아보기