해시와 맵

해시와 맵 (Hashes and maps)

키-값 쌍으로 데이터를 묶어 다루는 일은 아주 흔한데, Raku에서는 그게 해시(hash)와 맵(map)이에요. 다른 언어에서는 딕셔너리해시 테이블, 또는 그냥 이라고 부르는 구조죠. Raku에서 연관 배열이 어떻게 동작하고, 어떤 타입으로 제약할 수 있는지를 정리한 문서예요.

출처: Raku 공식 문서 — Hashes and maps

본문

연관 역할과 연관 클래스 (The associative role and associative classes)

Associative 역할은 해시와 맵뿐 아니라 MixHash 같은 다른 클래스의 기반이 돼요. 이것은 연관 클래스에서 사용될 두 타입을 정의해요. 기본적으로 키로는 무엇이든 쓸 수 있고(말 그대로 Any를 서브클래싱하는 어떤 클래스든), 값으로는 어떤 객체든 쓸 수 있어요. 다만 키는 문자열로 강제돼요. 이 타입들은 ofkeyof 메서드로 접근할 수 있어요.

기본적으로 % 시길로 선언된 어떤 객체든 Associative 역할을 얻고, 기본적으로 해시처럼 동작해요. 하지만 이 역할은 위의 두 메서드와 기본 Hash 동작만 제공할 뿐이에요.

say (%).^name ; # OUTPUT: «Hash␤»

반대로 Associative 역할이 믹스인되지 않았다면 % 시길을 쓸 수 없어요. 이 역할은 연관 속성(property)이 없으므로, 해시 첨자 연산자(subscript operator)의 동작을 재정의해야 해요. 그러려면 몇 가지 함수를 오버라이드해야 해요.

class Logger does Associative[Cool,DateTime] {
    has %.store;

    method log( Cool $event ) {
        %.store{ DateTime.new( now ) } = $event;
    }

    multi method AT-KEY ( ::?CLASS:D: $key) {
        my @keys = %.store.keys.grep( /$key/ );
        %.store{ @keys };
    }

    multi method EXISTS-KEY (::?CLASS:D: $key) {
        %.store.keys.grep( /$key/ )??True!!False;
    }

    multi method DELETE-KEY (::?CLASS:D: $key) {
        X::Assignment::RO.new.throw;
    }

    multi method ASSIGN-KEY (::?CLASS:D: $key, $new) {
        X::Assignment::RO.new.throw;
    }

    multi method BIND-KEY (::?CLASS:D: $key, \new){
        X::Assignment::RO.new.throw;
    }
}
say Logger.of;                   # OUTPUT: «(Cool)␤»
my %logger := Logger.new;
say %logger.of;                  # OUTPUT: «(Cool)␤»

%logger.log( "Stuff" );
%logger.log( "More stuff");

say %logger<2018-05-26>;         # OUTPUT: «(More stuff Stuff)␤»
say %logger<2018-04-22>:exists;  # OUTPUT: «False␤»

이 경우 날짜(또는 그 일부)를 키로 쓸 수 있는 Associative 시맨틱스를 가진 로거를 정의하고 있어요. Associative를 그 특정 클래스들로 파라미터화하고 있으므로, of는 우리가 사용한 값 타입을 반환해요. 여기서는 Cool이에요(목록이나 문자열만 로그할 수 있어요). Associative 역할을 믹스인하면 % 시길을 쓸 권리가 생겨요. %-시길 변수는 기본적으로 Hash 타입을 얻으므로, 정의에서 바인딩이 필요해요.

이 로그는 append-only여야 해요. 그래서 연관 배열 은유에서 벗어나 log 메서드로 새 이벤트를 로그에 추가해요. 일단 추가되면 날짜로 검색하거나 존재 여부를 확인할 수 있어요. 전자는 AT-KEY multi 메서드를 오버라이드하고, 후자는 EXISTS-KEY를 오버라이드해야 해요. 마지막 두 문장에서 첨자 연산이 AT-KEY를 호출하고, :exists adverb가 EXISTS-KEY를 호출하는 방식을 보여줘요.

DELETE-KEY, ASSIGN-KEY, BIND-KEY도 오버라이드하지만 예외를 던지기 위해서만이에요. 키에 값을 할당, 삭제, 바인딩하려 하면 Cannot modify an immutable Str (value) 예외가 던져져요.

클래스를 associative로 만드는 것은 해시를 사용해 클래스와 함께 작업하는 매우 편리한 방법을 제공해요. Cro에서 그 예를 볼 수 있는데, 구조화된 요청을 정의하고 응답을 표현하기 위해 해시를 쓰는 편의를 광범위하게 활용해요.

가변 해시와 불변 맵 (Mutable hashes and immutable maps)

Hash는 키에서 값으로의 가변 매핑이에요 (다른 언어에서는 딕셔너리, 해시 테이블, 이라고 불러요). 값은 모두 스칼라 컨테이너이므로 할당할 수 있어요. 반면 Map은 불변이에요. 키가 값과 한 번 짝지어지면 그 짝은 바꿀 수 없어요.

맵과 해시는 보통 퍼센트 % 시길을 가진 변수에 저장돼요. % 시길은 Associative임을 나타내는 데 쓰여요.

해시와 맵 요소는 { } postcircumfix 연산자로 키를 통해 접근해요.

say %*ENV{'HOME', 'PATH'}.raku;
# OUTPUT: («"/home/camelia", "/usr/bin:/sbin:/bin")␤»

일반 Subscript 규칙이 적용되어, 보간이 있고 없고의 리터럴 문자열 목록에 대한 단축키가 제공돼요.

my %h = oranges => 'round', bananas => 'bendy';
say %h<oranges bananas>;
# OUTPUT: «(round bendy)␤»

my $fruit = 'bananas';
say %h«oranges "$fruit"»;
# OUTPUT: «(round bendy)␤»

사용하지 않은 키에 할당하기만 하면 새 쌍을 추가할 수 있어요.

my %h;
%h{'new key'} = 'new value';

해시 할당 (Hash assignment)

해시 변수에 요소 목록을 할당하면 먼저 변수를 비우고, 그다음 우항의 요소(짝수 개여야 해요)를 반복해요. 해시 키는 인용되지 않더라도 항상 문자열로 강제되지만, 이름에 공백이 있는 키는 인용해야 한다는 점에 유의하세요. 예를 들어 흔한 Pair 문법을 사용하면:

my %h = 1 => 'a', b => 2, '1 2' => 3;
say %h.keys.sort.raku;       # OUTPUT: «("1", "1 2", "b").Seq␤»
say %h.values.sort.raku      # OUTPUT: «(2, 3, "a").Seq␤»

키의 문자열화는 놀라운 결과를 낳을 수 있어요. 예를 들어 Raku 모듈 CSV::Parser는 한 모드에서 CSV 데이터 줄을 {UInt => 'value'} 쌍의 해시로 반환해요. 20개 필드 줄의 이 조각처럼 보일 수 있죠.

my %csv = '2' => 'a', '10' => 'b';
say %csv.keys.sort.raku;           # OUTPUT: «("10", "2").Seq␤»

정렬 결과는 보통 원하는 것이 아니에요. 정렬을 위해 문자열 값을 정수로 강제하는 Raku의 힘을 쓸 수 있어요. 그렇게 하는 방법은 여러 가지가 있지만, 이 방법이 초보자나 Raku가 아닌 독자에게 가장 "라인 노이즈"가 적을 거예요.

say %csv.keys.map(+*).sort.raku;   # OUTPUT: «(2, 10).Seq␤»

해시 생성자 목록에서는 Pair 문법을 쓰지 않아도 되고, 목록이 전체적으로(그리고 Pair 사이에서도) 짝수 개 요소를 가지는 한 일반 목록 값과 섞을 수 있어요. 요소가 Pair 타입이면(예: 'a => 1') 그 키는 새 해시 키로, 그 값은 그 키에 대한 새 해시 값으로 취해져요. 그렇지 않으면 값은 Str로 강제되어 해시 키로 쓰이고, 목록의 다음 요소가 대응 값으로 취해져요.

my %h = 'a', 'b', c => 'd', 'e', 'f';

이것은 다음과 같아요.

my %h = a => 'b', c => 'd', e => 'f';

또는

my %h = <a b c d e f>;

또는 심지어

my %h = %( a => 'b', c => 'd', e => 'f' );

대부분의 생성자를 사용할 때 홀수 개 요소가 있으면 오류를 볼 거예요.

my %h = <a b c>; # OUTPUT: «hash initializer expected...␤»

해시를 구성하는 다른 유효한 방법 두 가지가 있는데, 사용자는 조심해야 해요.

my %h = [ a => 'b', c => 'd', e => 'f' ]; # This format is NOT recommended.
                                          # It cannot be a constant and there
                                          # will be problems with nested hashes

또는

my $h = { a => 'b', c => 'd', e => 'f'};

중괄호는 %-시길 변수에 할당하지 않는 경우에만 쓰인다는 점에 유의하세요. %-시길 변수에 쓰면 Potential difficulties:␤ Useless use of hash composer on right side of hash assignment; did you mean := instead? 오류를 얻을 거예요. 이 오류가 나타내는 것처럼, 바인딩을 쓰면 중괄호를 쓸 수 있어요.

my %h := { a => 'b', c => 'd', e => 'f'};
say %h; # OUTPUT: «{a => b, c => d, e => f}␤»

중첩 해시도 같은 문법으로 정의할 수 있어요.

my %h =  e => f => 'g';
say %h<e><f>; # OUTPUT: «g␤»

하지만 여기서 정의하는 것은 Pair를 가리키는 키예요. 그것이 원하는 것이고 중첩 해시가 단일 키를 가지면 괜찮아요. 하지만 %h<e>Pair를 가리키게 되고 다음 결과를 낳아요.

my %h =  e => f => 'g';
%h<e><q> = 'k';
# OUTPUT: «Pair␤Cannot modify an immutable Str (Nil)␤  in block <unit>»

이것은 그러나 효과적으로 중첩 해시를 정의해요.

my %h =  e => { f => 'g' };
say %h<e>.^name;  # OUTPUT: «Hash␤»
say %h<e><f>;     # OUTPUT: «g␤»

값이 기대되는 곳에서 Pair를 만나면 그것은 해시 값으로 쓰여요.

my %h = 'a', 'b' => 'c';
say %h<a>.^name;            # OUTPUT: «Pair␤»
say %h<a>.key;              # OUTPUT: «b␤»

같은 키가 두 번 이상 나타나면, 그 마지막 발생의 값이 해시에 저장돼요.

my %h = a => 1, a => 2;
say %h<a>;                  # OUTPUT: «2␤»

% 시길이 없는 변수에 해시를 할당하려면 %() 해시 생성자를 쓰면 돼요.

my $h = %( a => 1, b => 2 );
say $h.^name;               # OUTPUT: «Hash␤»
say $h<a>;                  # OUTPUT: «1␤»

하나 이상의 값이 토픽 변수 $_를 참조하면, 할당의 우변은 Hash가 아니라 Block으로 해석돼요.

my @people = [
    %( id => "1A", firstName => "Andy", lastName => "Adams" ),
    %( id => "2B", firstName => "Beth", lastName => "Burke" ),
    # ...
];

sub lookup-user (Hash $h) { #`(Do something...) $h }

my @names = map {
    # While this creates a hash:
    my  $query = { name => "$person<firstName> $person<lastName>" };
    say $query.^name;      # OUTPUT: «Hash␤»

    # Doing this will create a Block. Oh no!
    my  $query2 = { name => "$_<firstName> $_<lastName>" };
    say $query2.^name;       # OUTPUT: «Block␤»
    say $query2<name>;       # fails

    CATCH { default { put .^name, ': ', .Str } };
    # OUTPUT: «X::AdHoc: Type Block does not support associative indexing.␤»
    lookup-user($query);
    # Type check failed in binding $h; expected Hash but got Block
}, @people;

%() 해시 생성자를 썼다면 이 문제를 피할 수 있었을 거예요. 중괄호는 Block을 만들 때만 쓰세요.

해시 슬라이스 (Hash slices)

슬라이스로 여러 키에 동시에 할당할 수 있어요.

my %h; %h<a b c> = 2 xx *; %h.raku.say;  # OUTPUT: «{:a(2), :b(2), :c(2)}␤»
my %h; %h<a b c> = ^3;     %h.raku.say;  # OUTPUT: «{:a(0), :b(1), :c(2)}␤»

비문자열 키 (Non-string keys / object hash)

기본적으로 { } 안의 키는 문자열로 강제돼요. 비문자열 키로 해시를 구성하려면 콜론 접두어를 쓰세요.

my $when = :{ (now) => "Instant", (DateTime.now) => "DateTime" };

객체를 키로 쓰면 키 조회에 <...> 구성체를 자주 쓸 수 없어요. 그것은 문자열과 올로모프만 만들기 때문이에요. 대신 {...}를 쓰세요.

:{  0  => 42 }<0>.say;   # Int    as key, IntStr in lookup; OUTPUT: «(Any)␤»
:{  0  => 42 }{0}.say;   # Int    as key, Int    in lookup; OUTPUT: «42␤»
:{ '0' => 42 }<0>.say;   # Str    as key, IntStr in lookup; OUTPUT: «(Any)␤»
:{ '0' => 42 }{'0'}.say; # Str    as key, Str    in lookup; OUTPUT: «42␤»
:{ <0> => 42 }<0>.say;   # IntStr as key, IntStr in lookup; OUTPUT: «42␤»

참고: Rakudo 구현은 현재 :{ }{ }와 같은 규칙을 잘못 적용해 특정 상황에서 Block을 구성할 수 있어요. 이를 피하려면 파라미터화된 Hash를 직접 인스턴스화하면 돼요. %-시길 변수의 파라미터화도 지원돼요.

my Num %foo1      = "0" => 0e0; # Str keys and Num values
my     %foo2{Int} =  0  => "x"; # Int keys and Any values
my Num %foo3{Int} =  0  => 0e0; # Int keys and Num values
Hash[Num,Int].new: 0, 0e0;      # Int keys and Num values

이제 키로 사용하는 객체를 해시에 제공하는 객체와 정확히 같은 객체로 보존하는 해시를 정의하고 싶다면, 객체 해시(object hash)가 바로 당신이 찾는 것이에요.

my %intervals{Instant};
my $first-instant = now;
%intervals{ $first-instant } = "Our first milestone.";
sleep 1;
my $second-instant = now;
%intervals{ $second-instant } = "Logging this Instant for spurious raisins.";
for %intervals.sort -> (:$key, :$value) {
    state $last-instant //= $key;
    say "We noted '$value' at $key, with an interval of {$key - $last-instant}";
    $last-instant = $key;
}

이 예제는 Instant 타입 키만 받는 객체 해시로 기초적이면서도 타입 안전한 로깅 메커니즘을 구현해요. 이전 Instant를 추적하기 위해 이름 있는 상태 변수를 사용해 간격(interval)을 제공해요.

객체 해시의 요점은 키를 객체 그 자체로 유지하는 거예요. 현재 객체 해시는 객체의 WHICH 메서드를 사용하는데, 이는 모든 가변 객체에 고유 식별자를 반환해요. 이것은 객체 정체성 연산자(===)가 기대는 초석이에요. .keys의 순서는 정의되어 있지 않고, 어떤 익명 목록도 다른 익명 목록과 절대 ===하지 않으므로, 여기서 순서와 컨테이너가 정말 중요해요.

my %intervals{Instant};
my $first-instant = now;
%intervals{ $first-instant } = "Our first milestone.";
sleep 1;
my $second-instant = now;
%intervals{ $second-instant } = "Logging this Instant for spurious raisins.";
say ($first-instant, $second-instant) ~~ %intervals.keys;       # OUTPUT: «False␤»
say ($first-instant, $second-instant) ~~ %intervals.keys.sort;  # OUTPUT: «True␤»
say ($first-instant, $second-instant) === %intervals.keys.sort; # OUTPUT: «False␤»
say $first-instant === %intervals.keys.sort[0];                 # OUTPUT: «True␤»

Instant가 자체 비교 메서드를 정의하므로, 우리 예제에서 cmp에 따른 정렬은 항상 가장 이른 instant 객체를 반환하는 List의 첫 요소로 제공해요.

해시에 어떤 객체든 받고 싶다면 Any를 쓰면 돼요!

my %h{Any};
%h{(now)} = "This is an Instant";
%h{(DateTime.now)} = "This is a DateTime, which is not an Instant";
%h{"completely different"} = "Monty Python references are neither DateTimes nor Instants";

바인딩을 사용하는 더 간결한 문법이 있어요.

my %h := :{ (now) => "Instant", (DateTime.now) => "DateTime" };

객체 해시는 매우 확실하고 특정한 객체에 관한 것이므로 바인딩이 필요해요. 바인딩은 그런 것을 추적하는 데 아주 좋지만, 할당은 그다지 신경 쓰지 않아요.

6.d가 출시된 이후로 Junction도 해시 키로 쓸 수 있어요. 결과는 키로 사용된 것과 같은 타입의 Junction이 돼요.

my %hash = %( a => 1, b => 2, c=> 3);
say %hash{"a"|"c"};   # OUTPUT: «any(1, 3)␤»
say %hash{"b"^"c"};   # OUTPUT: «one(2, 3)␤»
say %hash{"a" & "c"}; # OUTPUT: «all(1, 3)␤»

어떤 종류의 Junction이든 키를 정의하는 데 쓰면, Junction의 요소들을 별도의 키로 정의하는 것과 같은 효과가 있어요.

my %hash = %( "a"|"b" => 1, c => 2 );
say %hash{"b"|"c"};       # OUTPUT: «any(1, 2)␤»

값 타입 제약 (Constraint value types)

선언자와 이름 사이에 타입 객체를 놓아 Hash의 모든 값의 타입을 제약할 수 있어요.

my Int %h;
put %h<Goku>   = 900;

try {
    %h<Vegeta> = "string";
    CATCH { when X::TypeCheck::Binding { .message.put } }
}

# OUTPUT:
# 9001
# Type check failed in assignment to %h; expected Int but got Str ("string")

더 읽기 쉬운 문법으로 같은 것을 할 수 있어요.

my %h of Int; # the same as my Int %h

Hash의 모든 키의 타입을 제약하고 싶으면 변수 이름 뒤에 {Type}을 추가하세요.

my %h{Int};

이 두 제약을 함께 놓을 수도 있어요.

my %h{Int} of Int;
put %h{21} = 42;

try {
    %h{0} = "String";
    CATCH { when X::TypeCheck::Binding { .message.put } }
}

try {
    %h<string> = 42;
    CATCH { when X::TypeCheck::Binding { .message.put } }
}

try {
    %h<string> = "String";
    CATCH { when X::TypeCheck::Binding { .message.put } }
}

# OUTPUT:
# 42
# Type check failed in binding to parameter 'assignval'; expected Int but got Str ("String")
# Type check failed in binding to parameter 'key'; expected Int but got Str ("string")
# Type check failed in binding to parameter 'key'; expected Int but got Str ("string")

해시 키와 값 순회 (Looping over hash keys and values)

해시 요소를 처리하는 흔한 관용구는 키와 값을 순회하는 거예요. 예를 들어

my %vowels = 'a' => 1, 'e' => 2, 'i' => 3, 'o' => 4, 'u' => 5;
for %vowels.kv -> $vowel, $index {
  "$vowel: $index".say;
}

이것은 이 비슷한 출력을 줘요.

a: 1
e: 2
o: 4
u: 5
i: 3

여기서 kv 메서드로 해시에서 키와 그 각각의 값을 추출해 이 값들을 루프에 전달했어요.

출력되는 키와 값의 순서는 신뢰할 수 없다는 점에 유의하세요. 해시 요소는 같은 프로그램의 다른 실행에서 항상 메모리에 같은 방식으로 저장되지 않아요. 사실 2018.05 릴리스부터 순서는 매 호출마다 다르게 보장돼요. 때로 요소를 해시의 키 같은 것으로 정렬해 처리하고 싶을 때가 있어요. 모음을 알파벳순으로 출력하고 싶다면 이렇게 쓰면 돼요.

my %vowels = 'a' => 1, 'e' => 2, 'i' => 3, 'o' => 4, 'u' => 5;
for %vowels.sort(*.key)>>.kv -> ($vowel, $index) {
  "$vowel: $index".say;
}

이것은 원하는 대로 알파벳순으로

a: 1
e: 2
i: 3
o: 4
u: 5

을 출력해요. 이 결과를 얻기 위해 모음을 키로 정렬하고(%vowels.sort(*.key)), 단항 >> 하이퍼연산자로 각 요소에 .kv 메서드를 적용해 키/값 목록의 List를 얻었어요. 키/값을 추출하려면 변수들을 괄호로 감싸야 해요.

대안 솔루션은 결과 목록을 펼치는 거예요. 그러면 키/값 쌍을 일반 .kv와 같은 방식으로 접근할 수 있어요.

my %vowels = 'a' => 1, 'e' => 2, 'i' => 3, 'o' => 4, 'u' => 5;
for %vowels.sort(*.key)>>.kv.flat -> $vowel, $index {
  "$vowel: $index".say;
}

구조 분해(destructuring)를 사용해 Hash를 순회할 수도 있어요.

값의 제자리 편집 (In place editing of values)

해시를 순회하면서 그 값을 수정하고 싶을 때가 있을 수 있어요.

my %answers = illuminatus => 23, hitchhikers => 42;
# OUTPUT: «hitchhikers => 42, illuminatus => 23»
for %answers.values -> $v { $v += 10 }; # Fails
CATCH { default { put .^name, ': ', .Str } };
# OUTPUT: «X::AdHoc: Cannot assign to a readonly variable or a value␤»

이는 전통적으로 키와 값을 둘 다 보내는 방식으로 달성돼요.

my %answers = illuminatus => 23, hitchhikers => 42;
for %answers.kv -> $k,$v { %answers{$k} = $v + 10 };

하지만 블록의 시그니처를 활용해 값에 읽기-쓰기 접근을 원한다고 지정하는 것도 가능해요.

my %answers = illuminatus => 23, hitchhikers => 42;
for %answers.values -> $v is rw { $v += 10 };

해시 키의 제자리 편집은 객체 해시의 경우에도 직접 할 수는 없어요. 하지만 키를 삭제하고 새 키/값 쌍을 추가하면 같은 결과를 얻을 수 있어요. 예를 들어 이 해시가 주어졌다면

my %h = a => 1, b => 2;
for %h.keys.sort -> $k {
    # use sort to ease output comparisons
    print "$k => {%h{$k}}; ";
}
say ''; # OUTPUT: «a => 1; b => 2; ␤»

키 'b'를 'bb'로 바꾸되 'b'의 값을 새 키의 값으로 유지하려면

for %h.keys -> $k {
    if $k eq 'b' {
        %h<bb> = %h{$k}:delete;
    }
}
for %h.keys.sort -> $k {
    print "$k => {%h{$k}}; ";
}
say ''; # OUTPUT: «a => 1; bb => 2; ␤»