컨테이너

컨테이너 (Containers)

이 문서는 Raku에서 원시 데이터(raw data), 변수, 컨테이너가 서로 어떻게 관계 맺는지 저수준에서 설명해요. Raku에서 쓰이는 서로 다른 컨테이너 타입과, 여기에 적용할 수 있는 할당(assign), 바인딩(bind), 평탄화(flatten) 같은 동작을 다루고요. 자기 참조 데이터, 타입 제약, 커스텀 컨테이너 같은 좀 더 고급 주제는 끝부분에서 다룹니다.

출처: Raku Docs - Containers

Raku의 순서 있는 컨테이너 종류를 더 깊이 보고 싶다면 lists, sequences, and arrays 문서를, 순서 없는 컨테이너는 sets, bags, and mixes 문서를 보세요.

변수란 무엇일까요? (What is a variable?)

어떤 사람들은 "모든 게 객체다"라고 말하길 좋아해요. 하지만 사실 변수는 Raku에서 사용자에게 노출되는 객체가 아닙니다.

컴파일러가 my $x 같은 변수 스코프 선언을 만나면, 그것을 내부 심볼 테이블에 등록해요. 이 내부 심볼 테이블은 선언되지 않은 변수를 감지하고, 그 변수에 대한 코드 생성을 올바른 스코프에 연결하는 데 쓰입니다.

런타임에는 변수가 렉시컬 패드(lexical pad), 줄여서 *렉스패드(lexpad)*의 항목으로 나타나요. 이것은 각 변수에 대한 포인터를 저장하는 스코프별 데이터 구조입니다.

my $x의 경우, 변수 $x의 lexpad 항목은 Scalar 타입의 객체, 보통 *컨테이너(the container)*라고 불리는 것을 가리키는 포인터예요.

스칼라 컨테이너 (Scalar containers)

Scalar 타입의 객체는 Raku 곳곳에 있지만, 직접 객체로 보는 일은 드물어요. 왜냐하면 대부분의 연산이 *디컨테이너화(decontainerize)*를 하거든요. 즉 Scalar 컨테이너 자체가 아니라 그 내용물에 대해 동작한다는 뜻이에요.

이런 코드에서

my $x = 42;
say $x;

할당 $x = 42Int 객체 42에 대한 포인터를, $x의 lexpad 항목이 가리키는 스칼라 컨테이너에 저장합니다.

할당 연산자는 왼쪽의 컨테이너에게 오른쪽 값을 저장하라고 요청해요. 정확히 무슨 의미인지는 컨테이너 타입이 정합니다. Scalar에게는 "이전에 저장된 값을 새 값으로 교체해라"라는 뜻이에요.

서브루틴 시그니처가 컨테이너를 넘겨주는 것을 허용한다는 점에 주목하세요.

sub f($a is rw) {
    $a = 23;
}
my $x = 42;
f($x);
say $x;         # OUTPUT: «23␤»

서브루틴 안에서 $a의 lexpad 항목은, 밖에서 $x가 가리키는 것과 같은 컨테이너를 가리켜요. 그래서 $a에 값을 할당하면 $x의 내용도 바뀌는 겁니다.

마찬가지로, 루틴이 is rw로 표시되어 있으면 컨테이너를 반환할 수 있어요.

my $x = 23;
sub f() is rw { $x };
f() = 42;
say $x;         # OUTPUT: «42␤»

명시적 반환의 경우에는 return 대신 return-rw를 써야 해요.

컨테이너를 반환하는 것이 바로 is rw 속성 접근자(accessor)가 동작하는 방식이에요. 그래서

class A {
    has $.attr is rw;
}

는 다음과 같습니다.

class A {
    has $!attr;
    method attr() is rw { $!attr }
}

스칼라 컨테이너는 타입 검사와 대부분의 읽기 전용 접근에 투명해요. .VAR를 쓰면 그 모습이 드러나죠.

my $x = 42;
say $x.^name;       # OUTPUT: «Int␤»
say $x.VAR.^name;   # OUTPUT: «Scalar␤»

그리고 파라미터의 is rw는 쓰기 가능한 Scalar 컨테이너가 있어야 해요.

sub f($x is rw) { say $x };
f 42;
CATCH { default { say .^name, ': ', .Str } };
# OUTPUT: «X::Parameter::RW: Parameter '$x' expected a writable container, but got Int value␤»

호출 가능 컨테이너 (Callable containers)

호출 가능 컨테이너는 Routine 호출의 문법과, 컨테이너에 저장된 객체의 CALL-ME 메서드의 실제 호출 사이의 다리 역할을 해요. 시길(sigil) &는 컨테이너를 선언할 때 필요하고, Callable을 실행할 때는 빼야 합니다. 기본 타입 제약은 Callable이에요.

my &callable = -> $ν { say "$ν is ", $ν ~~ Int ?? "whole" !! "not whole" }
callable(⅓);   # OUTPUT: «0.333333 is not whole␤»
callable(3);   # OUTPUT: «3 is whole␤»

컨테이너에 저장된 값을 가리킬 때는 시길을 제공해야 해요. 이 덕분에 Routine들을 호출의 인자로 쓸 수 있게 됩니다.

sub f() {}
my &g = sub {}
sub caller(&c1, &c2){ c1, c2 }
caller(&f, &g);

바인딩 (Binding)

할당 옆에, Raku는 := 연산자로 *바인딩(binding)*도 지원해요. 값이나 컨테이너를 변수에 바인딩하면, 변수의 lexpad 항목이 수정됩니다(그것이 가리키는 컨테이너만 바뀌는 게 아니라요). 만약 이렇게 쓰면

my $x := 42;

$x의 lexpad 항목이 Int 42를 직접 가리켜요. 그 뜻은 이제 할당을 할 수 없다는 걸 의미합니다.

my $x := 42;
$x = 23;
CATCH { default { say .^name, ': ', .Str } };
# OUTPUT: «X::AdHoc: Cannot assign to an immutable value␤»

변수를 다른 변수에 바인딩할 수도 있어요.

my $a = 0;
my $b = 0;
$a := $b;
$b = 42;
say $a;         # OUTPUT: «42␤»

여기서는 최초 바인딩 이후 $a$b의 lexpad 항목이 같은 스칼라 컨테이너를 가리켜서, 한 변수에 할당하면 다른 변수의 내용도 바뀝니다.

이 상황은 이미 봤던 거예요. is rw로 표시된 시그니처 파라미터에서 정확히 일어났던 일이죠.

시길이 없는 변수와 is raw 특질(trait)이 붙은 파라미터는 (=:=든) 항상 바인딩합니다.

my $a = 42;
my \b = $a;
b++;
say $a;         # OUTPUT: «43␤»

sub f($c is raw) { $c++ }
f($a);
say $a;         # OUTPUT: «44␤»

스칼라 컨테이너와 리스트류 (Scalar containers and listy things)

Raku에는 의미가 조금씩 다른 여러 위치형(positional) 컨테이너 타입이 있어요. 가장 기본적인 것은 콤마 연산자가 만드는 List입니다.

say (1, 2, 3).^name;    # OUTPUT: «List␤»

리스트는 불변이에요. 즉 리스트의 원소 개수를 바꿀 수 없어요. 하지만 어떤 원소가 우연히 스칼라 컨테이너라면, 그 원소에는 여전히 할당할 수 있습니다.

my $x = 42;
($x, 1, 2)[0] = 23;
say $x;                 # OUTPUT: «23␤»
($x, 1, 2)[1] = 23;     # Cannot modify an immutable value
CATCH { default { say .^name, ': ', .Str } };
# OUTPUT: «X::Assignment::RO: Cannot modify an immutable Int␤»

그래서 리스트는 자신의 원소가 값인지 컨테이너인지 신경 쓰지 않아요. 그냥 주어진 것을 저장하고 꺼내줄 뿐이죠.

리스트는 게으를 수도 있어요. 그 경우 끝부분의 원소들은 이터레이터에서 필요할 때 생성됩니다.

Array는 리스트와 거의 같지만, 모든 원소를 강제로 컨테이너로 만든다는 점이 달라요. 그래서 항상 원소에 할당할 수 있습니다.

my @a = 1, 2, 3;
@a[0] = 42;
say @a;         # OUTPUT: «[42 2 3]␤»

@a는 실제로 스칼라 컨테이너 세 개를 저장해요. @a[0]은 그중 하나를 반환하고, 할당 연산자는 그 컨테이너에 저장된 정수 값을 새 값인 42로 바꿉니다.

배열 변수에 할당하기와 바인딩하기 (Assigning and binding to array variables)

스칼라 변수에 할당하는 것과 배열 변수에 할당하는 것은 둘 다 같은 일을 해요. 옛 값(들)을 버리고 새 값(들)을 넣는 거죠.

그래도 둘이 얼마나 다른지 쉽게 관찰할 수 있어요.

my $x = 42; say $x.^name;   # OUTPUT: «Int␤»
my @a = 42; say @a.^name;   # OUTPUT: «Array␤»

그 이유는 Scalar 컨테이너 타입이 자신을 잘 숨기지만, Array는 그런 노력을 하지 않기 때문이에요. 또한 배열 변수에 할당하는 것은 강제적(coercive)이라서, 배열이 아닌 값을 배열 변수에 할당할 수 있습니다.

Array가 아닌 것을 배열 변수에 넣으려면 바인딩하면 돼요.

my @a := (1, 2, 3);
say @a.^name;               # OUTPUT: «List␤»

배열 원소에 바인딩하기 (Binding to array elements)

재미있는 사실로, Raku는 배열 원소에 바인딩하는 것을 지원해요.

my @a = (1, 2, 3);
@a[0] := my $x;
$x = 42;
say @a;                     # OUTPUT: «[42 2 3]␤»

앞선 설명을 읽고 이해했다면, 이게 어떻게 가능한지 궁금할 거예요. 어쨌든 변수에 바인딩하려면 그 변수에 대한 lexpad 항목이 필요한데, 배열에 대한 lexpad 항목은 있지만 배열의 각 원소에 대한 lexpad 항목은 없으니까요. 런타임에 lexpad를 늘릴 수 없기 때문이에요.

정답은, 배열 원소에 바인딩하는 것이 문법 수준에서 인식되어, 일반적인 바인딩 연산용 코드 대신 특수 메서드(이름은 BIND-POS)가 배열에 호출된다는 점이에요. 이 메서드가 배열 원소에 대한 바인딩을 처리합니다. 해시 원소에 바인딩하는 BIND-KEY라는 동등한 메서드도 있고요.

지원되긴 하지만, 일반적으로 컨테이너가 아닌 것을 배열 원소에 직접 바인딩하는 것은 피해야 해요. 나중에 배열을 사용할 때 직관에 반하는 결과를 낼 수 있으니까요.

my @a = (1, 2, 3);
@a[0] := 42;         # This is not recommended, use assignment instead.
my $b := 42;
@a[1] := $b;         # Nor is this.
@a[2] = $b;          # ...but this is fine.
@a[1, 2] := 1, 2;    # runtime error: X::Bind::Slice
CATCH { default { say .^name, ': ', .Str } };
# OUTPUT: «X::Bind::Slice: Cannot bind to Array slice␤»

List와 Array를 섞는 연산들은 일반적으로 이런 일이 우연히 일어나지 않게 보호합니다.

평탄화, 항목, 컨테이너 (Flattening, items and containers)

Raku의 %@ 시길은 일반적으로 반복 구조에 '여러 값'을 나타내는 반면, $ 시길은 '단일 값'을 나타내요.

my @a = 1, 2, 3;
for @a { };         # 3 iterations
my $a = (1, 2, 3);
for $a { };         # 1 iteration

@ 시길 변수는 리스트 컨텍스트에서 평탄화되지 않아요.

my @a = 1, 2, 3;
my @b = @a, 4, 5;
say @b.elems;               # OUTPUT: «3␤»

스칼라 컨테이너 안에 있지 않은 부리스트를 평탄화하는 연산이 있어요. 슬러피 파라미터(*@a)와 flat의 명시적 호출이 그것들이에요.

my @a = 1, 2, 3;
say (flat @a, 4, 5).elems;  # OUTPUT: «5␤»

sub f(*@x) { @x.elems };
say f @a, 4, 5;             # OUTPUT: «5␤»

|를 써서 Slip을 만들 수도 있는데, 그렇게 하면 리스트를 다른 리스트에 끼워 넣어요.

my @l := 1, 2, (3, 4, (5, 6)), [7, 8, (9, 10)];
say (|@l, 11, 12);    # OUTPUT: «(1 2 (3 4 (5 6)) [7 8 (9 10)] 11 12)␤»
say (flat @l, 11, 12) # OUTPUT: «(1 2 3 4 5 6 7 8 (9 10) 11 12)␤»

첫 번째 경우 @l의 각 원소가 결과 리스트의 대응 원소로 *슬립(slipped)*돼요. 반면 flat은 포함된 배열의 원소까지 포함해서 모든 원소를 평탄화하는데, (9 10)은 예외입니다.

앞서 언급했듯이 스칼라 컨테이너가 그 평탄화를 막아요.

sub f(*@x) { @x.elems };
my @a = 1, 2, 3;
say f $@a, 4, 5;            # OUTPUT: «3␤»

@ 문자는 접두사로도 쓸 수 있는데, 인자를 리스트로 강제 변환해서 스칼라 컨테이너를 제거해요.

my $x = (1, 2, 3);
.say for @$x;               # 3 iterations

다만 리스트가 아닌 항목을 디컨테이너화할 때는 decont 연산자 <>가 더 적절해요.

my $x = ^Inf .grep: *.is-prime;
say "$_ is prime" for @$x;  # WRONG! List keeps values, thus leaking memory
say "$_ is prime" for $x<>; # RIGHT. Simply decontainerize the Seq

my $y := ^Inf .grep: *.is-prime; # Even better; no Scalars involved at all

메서드는 일반적으로 자신의 invocant가 스칼라 안에 있든 없든 신경 쓰지 않아요.

my $x = (1, 2, 3);
$x.map(*.say);              # 3 iterations

위 코드는 한 개가 아니라 세 원소의 리스트에 대해 map을 수행합니다.

자기 참조 데이터 (Self-referential data)

ArrayHash를 포함한 컨테이너 타입들은 자기 참조 구조를 만들 수 있게 해줘요.

my @a;
@a[0] = @a;
put @a.raku;
# OUTPUT: «((my @Array_75093712) = [@Array_75093712,])␤»

Raku가 자기 참조 데이터의 생성과 사용을 막지는 않지만, 그렇게 하면 데이터를 덤프하려다 무한 루프에 빠질 수 있어요. 최후의 수단으로 Promises를 써서 timeout을 처리할 수 있습니다.

타입 제약 (Type constraints)

모든 컨테이너는 타입 객체subset 형태의 타입 제약을 가질 수 있어요. 둘 다 선언자(declarator)와 변수 이름 사이, 또는 of 특질 뒤에 놓을 수 있습니다. 이 제약은 변수의 속성이지 컨테이너의 속성이 아니에요.

subset Three-letter of Str where .chars == 3;
my Three-letter $acronym = "ÞFL";

이 경우 타입 제약은 (컴파일 타입으로 정의된) subset Three-letter입니다.

Scalar 컨테이너의 기본 타입 제약은 Mu예요. 컨테이너의 타입 제약을 들여다보는 것은 .VAR.of 메서드가 제공하는데, @% 시길 변수의 경우 값에 대한 제약을 줍니다.

my Str $x;
say $x.VAR.of;  # OUTPUT: «(Str)␤»
my Num @a;
say @a.VAR.of;  # OUTPUT: «(Num)␤»
my Int %h;
say %h.VAR.of;  # OUTPUT: «(Int)␤»

정의됨(definedness) 제약 (Definedness constraints)

컨테이너는 변수가 정의되어 있도록 강제할 수도 있어요. 선언에 스마일리를 넣으면 됩니다.

my Int:D $def = 3;
say $def;   # OUTPUT: «3␤»
$def = Int; # Typecheck failure

선언에서 변수를 초기화하는 것도 필요해요. 어쨌든 정의되지 않은 채로 둘 수는 없으니까요.

한 스코프에서 선언된 모든 변수에 이 제약을 적용하려면 default defined variables pragma를 쓸 수도 있어요. 다른 언어(변수가 항상 정의되어 있는)에서 온 사람들이라면 한 번 볼 만합니다.

커스텀 컨테이너 (Custom containers)

커스텀 컨테이너를 제공하기 위해 Raku는 Proxy 클래스를 써요. 생성자는 두 인자를 받는데, 컨테이너에서 값을 가져오거나(fetch) 저장할(stor) 때 호출되는 메서드를 가리키는 FETCHSTORE예요. 타입 검사는 컨테이너 자체가 하지 않으며, 읽기 전용 같은 다른 제약도 깨질 수 있어요. 따라서 반환되는 값은 바인딩된 변수의 타입과 같은 타입이어야 해요. Raku에서 타입을 다루려면 타입 캡처를 사용할 수 있습니다.

sub lucky(::T $type) {
    my T $c-value; # closure variable
    return-rw Proxy.new(
        FETCH => method () { $c-value },
        STORE => method (T $new-value) {
            X::OutOfRange.new(what => 'number', got => '13', range => '-∞..12, 14..∞').throw
                if $new-value == 13;
            $c-value = $new-value;
        }
    );
}

my Int $a := lucky(Int);
say $a = 12;    # OUTPUT: «12␤»
say $a = 'FOO'; # X::TypeCheck::Binding
say $a = 13;    # X::OutOfRange
CATCH { default { say .^name, ': ', .Str } };