structures — 데이터 구조 다루기
structures — 데이터 구조 다루기 (스칼라·복합·함수형)
Raku에서 데이터를 어떻게 구조화하고 다루는지 정리한 페이지예요. 내부 구조가 없는 스칼라부터, 순서 매기는 Positional/키-값 Associative 복합 구조, 함수를 1급 데이터로 다루는 Callable, 그리고 지연 평가(laziness)와 introspection까지를 폭넓게 다뤄요.
스칼라 구조
어떤 클래스는 내부 구조가 없어서, 그 일부에 접근하려면 특정 메서드를 써야 해요. 숫자, 문자열, 그리고 몇몇 단일체(monolithic) 클래스가 여기 포함돼요. $ 시길을 쓰지만, 복합 데이터 구조도 쓸 수는 있어요.
my $just-a-number = 7;
my $just-a-string = "8";
Scalar 클래스가 있는데, $ 시길로 선언한 변수에 기본값을 할당하기 위해 내부적으로 사용돼요.
my $just-a-number = 333;
say $just-a-number.VAR.^name; # OUTPUT: «Scalar»
어떤 복합 데이터 구조든 항목 맥락화자(item contextualizer) $로 스칼라화할 수 있어요:
(1, 2, 3, $(4, 5))[3].VAR.^name.say; # OUTPUT: «Scalar»
다만 이렇게 하면 해당 컨텍스트에서 스칼라로 취급된다는 뜻이에요. 그래도 내부 구조에는 여전히 접근할 수 있어요.
(1, 2, 3, $(4, 5))[3][0].say; # OUTPUT: «4»
흥미로운 부수 효과(아니면 의도된 기능)로, 스칼라화는 복합 구조의 **동일성(identity)**을 보존해요.
for ^2 {
my @list = (1, 1);
say @list.WHICH;
} # OUTPUT: «Array|93947995146096Array|93947995700032»
(1, 1)이 할당될 때마다 만들어지는 변수는 === 기준으로 서로 다른데, 그 증거가 내부 포인터 표현 값이 매번 달라지는 거예요. 그런데:
for ^2 {
my $list = (1, 1);
say $list.WHICH;
} # OUTPUT: «List|94674814008432List|94674814008432»
이 경우 $list는 Scalar 시길을 쓰므로 Scalar가 돼요. 같은 값을 가진 스칼라는 완전히 같아져요. 포인터를 출력해 보면 알 수 있죠.
복합 데이터 구조
복합 데이터 구조는 첫 단계 요소에 어떻게 접근하느냐에 따라 두 큰 범주로 나뉘어요. 하나는 Positional(리스트 같은), 다른 하나는 Associative(키-값 쌍 같은)예요. 일반적으로 객체를 포함한 복합 데이터 구조는 둘의 조합이며, 객체의 프로퍼티는 키-값 쌍으로 동화(assimilate)돼요. 모든 객체가 Mu를 상속하지만, 일반적인 복합 객체는 보통 Any의 하위 클래스 인스턴스예요. 이론상 그렇게 하지 않고 Positional이나 Associative를 믹스인할 수도 있지만, 복합 데이터 구조에 적용되는 대부분의 메서드는 Any에 구현되어 있어요.
이 복합 구조를 탐색하는 건 도전이지만, Raku는 deepmap과 duckmap 두 함수를 제공해요. 전자는 모든 요소를 순서대로 돌면서 블록이 요구하는 일을 해요:
say [[1, 2, [3, 4]],[[5, 6, [7, 8]]]].deepmap( *.elems );
# OUTPUT: «[[1 1 [1 1]] [1 1 [1 1]]]»
가장 깊은 곳까지 내려가서 거기에 elems를 적용하니 1을 돌려줘요. 반면 duckmap은 더 복잡한 연산을 할 수 있어요:
say [[1, 2, [3, 4]], [[5, 6, [7, 8]]]].duckmap:
-> $array where .elems == 2 { $array.elems };
# OUTPUT: «[[1 2 2] [5 6 2]]»
이 경우 구조 속으로 뛰어들지만, 블록의 조건을 충족하지 못하면 요소 자체를 돌려주고(1, 2), 충족하면 배열의 요소 수를 돌려줘요 (각 하위 배열 끝의 두 2).
deepmap과 duckmap은 Any 메서드이므로 Associative 배열에도 적용돼요:
say %( first => [1, 2], second => [3,4] ).deepmap( *.elems );
# OUTPUT: «{first => [1 1], second => [1 1]}»
이 경우에는 값인 모든 리스트/배열에만 적용되고 키는 남겨 둬요.
Positional과 Associative는 서로 변환될 수 있어요.
say %( first => [1, 2], second => [3,4] ).list[0];
# OUTPUT: «second => [3 4]»
단, 이 경우 Rakudo 2018.05 이상에서는 실행할 때마다 다른 값이 나와요. 해시는 키-값 쌍의 리스트로 바뀌지만 순서가 보장되지 않아요. 반대 방향 연산도 가능한데, 리스트의 요소 수가 짝수여야 해요 (홀수면 오류가 나요):
say <a b c d>.Hash # OUTPUT: «{a => b, c => d}»
하지만:
say <a b c d>.Hash.kv # OUTPUT: «(c d a b)»
이건 실행할 때마다 다른 값을 얻어요. kv는 모든 Pair를 리스트로 바꾸니까요.
복합 데이터 구조는 일반적으로 Iterable이기도 해요. 이 구조에서 이터레이터를 만들면 프로그램이 구조의 첫 단계를 하나씩 방문할 수 있어요:
.say for 'א'..'ס'; # OUTPUT: «אבגדהוזחטיךכלםמןנס»
'א'..'ס'는 Range라는 복합 데이터 구조예요. 앞에 for를 붙이면 리스트가 소진될 때까지 반복해요. Iterable 역할의 iterator 메서드를 오버라이드해서 복합 구조에 for를 쓸 수도 있어요:
class SortedArray is Array {
method iterator() {
self.sort.iterator
}
};
my @thing := SortedArray.new([3,2,1,4]);
.say for @thing; # OUTPUT: «1234»
for는 @thing의 iterator 메서드를 직접 호출해서 배열 요소를 순서대로 돌려줘요. 반복에 대한 훨씬 자세한 내용은 전용 페이지를 참고해요.
함수형 구조
Raku는 함수형 언어라서 함수가 1급 데이터 구조예요. 함수는 네 가지 근본 역할 중 4번째인 Callable 역할을 따라요. Callable은 & 시길과 함께 쓰이지만, 대부분의 경우 단순함을 위해 시길을 생략해요. Callable에서는 이 시길 제거가 항상 허용돼요.
my &a-func= { (^($^þ)).Seq };
say a-func(3), a-func(7); # OUTPUT: «(0 1 2)(0 1 2 3 4 5 6)»
Callable은 인스턴스화할 수 없으므로, Block이 가장 단순한 callable 구조예요. 이 경우 이벤트를 기록했다가 꺼내 올 수 있는 블록을 구현해요:
my $logger = -> $event, $key = Nil {
state %store;
if ( $event ) {
%store{ DateTime.new( now ) } = $event;
} else {
%store.keys.grep( /$key/ )
}
}
$logger( "Stuff" );
$logger( "More stuff" );
say $logger( Nil, "2018-05-28" ); # OUTPUT: «(Stuff More stuff)»
Block은 Signature를 갖는데, 이 경우 인자가 두 개예요. 첫 번째는 기록될 이벤트, 두 번째는 이벤트를 꺼내 올 키예요. 둘은 독립적으로 쓰이지만, 의도는 호출 사이에 유지되는 state 변수의 사용을 보여 주는 거예요. 이 state 변수는 블록 안에 캡슐화되어 있고, 블록이 제공하는 간단한 API(두 번째 인자를 주고 호출하기)를 통하지 않고는 밖에서 접근할 수 없어요. 처음 두 호출은 이벤트 두 개를 기록하고, 예제 맨 아래의 세 번째 호출은 이 두 번째 방식으로 저장된 값을 꺼내 와요.
Block은 클론할 수 있어요:
my $clogger = $logger.clone;
$clogger( "Clone stuff" );
$clogger( "More clone stuff" );
say $clogger( Nil, "2018-05-28" );
# OUTPUT: «(Clone stuff More clone stuff)»
클로닝하면 state 변수가 재설정돼요. 클론 대신 API를 바꾸는 파사드를 만들 수도 있어요. 예를 들어 어떤 날짜의 로그를 꺼내 올 때 첫 인자로 Nil을 줄 필요를 없앨 수 있어요:
my $gets-logs = $logger.assuming( Nil, * );
$logger( %(changing => "Logs") );
say $gets-logs( "2018-05-28" );
# OUTPUT: «({changing => Logs} Stuff More stuff)»
assuming는 블록 호출을 감싸서, 필요한 인자에 값(여기서 Nil)을 주고 나머지는 *로 나타낸 인자에 넘겨줘요. 사실 이건 자연어로 "우리는 $logger를 첫 인자가 Nil이라고 가정하고 호출하고 있다"는 뜻이에요. 이 두 Block이 사실은 같은 블록에 작용하고 있음을 분명히 하려고 이름을 살짝 바꿀 수 있어요:
my $Logger = $logger.clone;
my $Logger::logs = $Logger.assuming( *, Nil );
my $Logger::get = $Logger.assuming( Nil, * );
$Logger::logs( <an array> );
$Logger::logs( %(key => 42) );
say $Logger::get( "2018-05-28" );
::는 보통 클래스 메서드 호출에 쓰이지만, 사실 변수 이름의 유효한 일부예요. 여기서는 관례적으로 $Logger::logs와 $Logger::get이 실제로는 $Logger를 호출한다는 걸 나타내는 데 썼어요. 그리고 $Logger는 클래스처럼 보이게 대문자로 썼죠. 이 튜토리얼의 핵심은 함수를 1급 시민으로 쓰고 state 변수를 함께 쓰면, 이런 흥미로운 디자인 패턴을 쓸 수 있다는 거예요.
이런 1급 데이터 구조로서 callable은 다른 종류의 데이터를 쓸 수 있는 곳이라면 어디든 쓸 수 있어요.
my @regex-check = ( /<alnum>/, /<alpha>/, /<punct>/ );
say @regex-check.map: "33af" ~~ *;
# OUTPUT: «(「3」 alnum => 「3」 「a」 alpha => 「a」 Nil)»
정규식은 사실 callable의 한 종류예요:
say /regex/.does( Callable ); # OUTPUT: «True»
그리고 위 예제에서는 배열에 저장된 정규식을 문자열 리터럴에 적용하고 있어요.
callable은 함수 합성 연산자 ∘로 합성할 수 있어요:
my $typer = -> $thing { $thing.^name ~ ' → ' ~ $thing };
my $Logger::withtype = $Logger::logs ∘ $typer;
$Logger::withtype( Pair.new( 'left', 'right' ) );
$Logger::withtype( ¾ );
say $Logger::get( "2018-05-28" );
# OUTPUT: «(Pair → left right Rat → 0.75)»
$typer를 앞서 정의한 $Logger::logs 함수와 합성해서, 객체를 타입 이름과 함께 기록하는 함수를 얻었어요. 필터링 같은 데 유용하죠. $Logger::withtype는 사실 직렬로 적용되는 두 함수로 구성된 복합 데이터 구조인데, 각 callable은 state를 유지할 수 있어서 객체 지향 영역의 객체 합성과 비슷한 디자인 패턴으로 복잡한 변형 callable을 만들 수 있어요. 각각의 경우에 어떤 프로그래밍 스타일이 가장 적합한지는 선택해야 해요.
데이터 구조 정의하고 제약하기
Raku는 데이터 구조를 정의하는 여러 방법과, 각 문제 영역에 가장 적합한 구조를 만들기 위한 다양한 제약 방법을 제공해요. 예를 들어 but는 역할이나 값을 값·변수에 믹스인해요:
my %not-scalar := %(2 => 3) but Associative[Int, Int];
say %not-scalar.^name; # OUTPUT: «Hash+{Associative[Int, Int]}»
say %not-scalar.of; # OUTPUT: «Associative[Int, Int]»
%not-scalar{3} = 4;
%not-scalar<thing> = 3;
say %not-scalar; # OUTPUT: «{2 => 3, 3 => 4, thing => 3}»
이 경우 but가 Associative[Int, Int] 역할을 믹스인해요. 변수의 타입이 % 시길이 부과하는 게 아니라 정의된 타입이 되도록 바인딩을 쓰고 있다는 점에 주목해요. 믹스인된 역할은 중괄호로 둘러싸여 name에 나타나요. 이게 무슨 뜻일까요? 그 역할에는 of와 keyof 두 메서드가 있는데, 역할을 믹스인하면 새 of가 호출돼요 (이전 of는 Hash의 기본 값 타입인 Mu를 돌려주죠). 하지만 그게 전부예요. 실제로 변수 타입을 바꾸는 건 아닌데, 다음 문장들에서 아무 키·값이나 쓰는 걸 보면 알 수 있어요.
하지만 이런 믹스인으로 변수에 새 기능을 줄 수는 있어요:
role Lastable {
method last() {
self.sort.reverse[0]
}
}
my %hash-plus := %( 3 => 33, 4 => 44) but Lastable;
say %hash-plus.sort[0]; # OUTPUT: «3 => 33»
say %hash-plus.last; # OUTPUT: «4 => 44»
Lastable에서 보편적인 self 변수를 써서 이 역할이 믹스인된 객체를 가리켜요. 이 경우엔 믹스인된 해시를 담지만, 다른 경우에는 다른 게 담기고 (또한 다르게 동작할 수도 있고요). 이 역할은 믹스인된 모든 변수에 last 메서드를 제공해서, 일반 변수에 새로 붙일 수 있는 기능을 만들어줘요. 역할은 does 키워드로 기존 변수에 추가할 수도 있어요.
부분집합(subset)은 변수가 가질 수 있는 값을 제약하는 데도 쓸 수 있어요. 이것은 Raku의 점진적 타이핑(gradual typing) 시도인데, 완전한 시도는 아니에요. 부분집합은 엄밀한 의미의 타입은 아니지만 런타임 타입 체크를 허용하니까요. 정규 타입에 타입 체크 기능을 더해서 더 풍부한 타입 시스템을 만들어 주고, 이런 게 가능해요:
subset OneOver where (1/$_).Int == 1/$_;
my OneOver $one-fraction = ⅓;
say $one-fraction; # OUTPUT: «0.333333»
반면 my OneOver $ = ⅔;는 타입 체크 오류를 일으켜요. 부분집합은 인자를 가리키는 데 Whatever, 즉 *를 쓸 수 있는데, 쓸 때마다 다른 인자로 인스턴스화되므로 정의 안에서 두 번 쓰면 오류가 나요. 여기서는 topic 단일 변수 $_로 인스턴스를 확인하고 있어요. 부분집합화는 시그니처 안에서 선언 없이 직접 할 수도 있어요.
무한 구조와 지연(laziness)
데이터 구조에 담긴 모든 데이터가 실제로 "거기 있다"고 가정할 수도 있지만, 꼭 그런 건 아니에요. 효율성 때문이거나 단순히 불가능하기 때문에, 구조의 요소는 실제로 필요해질 때 비로소 존재하게 돼요. 필요할 때 항목을 계산하는 이 방식을 reification(구체화) 또는 vivification라고 불러요.
# A list containing infinite number of un-reified Fibonacci numbers:
my @fibonacci = 1, 1, * + * … ∞;
# We reify 10 of them, looking up the first 10 of them with array index:
say @fibonacci[^10]; # OUTPUT: «(1 1 2 3 5 8 13 21 34 55)»
# We reify 5 more: 10 we already reified on previous line, and we need to
# reify 5 more to get the 15th element at index 14. Even though we need only
# the 15th element, the original Seq still has to reify all previous elements:
say @fibonacci[14]; # OUTPUT: «987»
위에서는 시퀀스 연산자로 만든 Seq를 구체화했지만, 다른 데이터 구조도 이 개념을 써요. 예를 들어 구체화되지 않은 Range는 두 끝점일 뿐이에요. 어떤 언어에서는 거대한 범위의 합을 계산하는 게 시간과 메모리를 많이 먹지만, Raku는 즉시 계산해요:
say sum 1 .. 9_999_999_999_999; # OUTPUT: «49999999999995000000000000»
왜일까요? 합이 Range를 vivify하지 않고도 계산될 수 있기 때문이에요. 즉 포함된 모든 요소를 알아내지 않아도 된다는 거죠. gather와 take로 나만의 즉시-구체화(on-demand reify)를 만들 수도 있어요:
my $seq = gather {
say "About to make 1st element"; take 1;
say "About to make 2nd element"; take 2;
}
say "Let's reify an element!";
say $seq[0];
say "Let's reify more!";
say $seq[1];
say "Both are reified now!";
say $seq[^2];
# OUTPUT:
# Let's reify an element!
# About to make 1st element
# 1
# Let's reify more!
# About to make 2nd element
# 2
# Both are reified now!
# (1 2)
출력을 보면 gather 안의 print 문들이 요소를 개별 구체화할 때만 실행되는 걸 알 수 있어요. 또한 요소가 딱 한 번 구체화돼요. 예제 마지막 줄에서 같은 요소를 다시 출력했을 때 gather 안의 메시지가 더 이상 출력되지 않은 것에서 드러나요. Seq의 캐시에서 이미 구체화된 요소를 썼기 때문이에요.
위에서 gather를 Positional 시길(@)이 아니라 Scalar 컨테이너($)에 할당한 점에 주목해요. @-시길 변수는 대부분 eager하기 때문이에요. 즉 할당된 것을 대부분 바로 구체화한다는 뜻이에요. 그렇게 하지 않는 유일한 때는 항목들이 is-lazy로 알려진 경우인데, 무한을 끝점으로 만든 우리 시퀀스처럼요. gather를 @-변수에 할당했다면 그 안의 say 문장들이 바로 출력됐을 거예요.
리스트를 완전히 vivify하는 또 다른 방법은 .elems를 호출하는 거예요. 그래서 리스트에 항목이 있는지 확인할 땐 .Bool 메서드(또는 그냥 if @array { … })를 쓰는 게 가장 좋아요. 요소가 있는지 알아내기 위해 모든 요소를 구체화할 필요가 없으니까요.
어떤 일을 하기 전에 리스트를 완전히 구체화하고 싶을 때도 있어요. 예를 들어 IO::Handle.lines는 Seq를 돌려줘요. 다음 코드에는 버그가 있는데, reification을 염두에 두고 찾아보세요:
my $fh = "/tmp/bar".IO.open;
my $lines = $fh.lines;
close $fh;
say $lines[0];
파일 핸들을 열고, .lines의 반환을 Scalar 변수에 할당했어요. 그래서 돌려받은 Seq는 바로 구체화되지 않아요. 그다음 파일 핸들을 close하고 $lines에서 요소를 출력하려 해요.
버그는 마지막 줄에서 $lines Seq를 구체화할 때쯤엔 이미 파일 핸들을 닫았기 때문이에요. Seq의 이터레이터가 요청한 항목을 생성하려고 하면 닫힌 핸들에서 읽으려 한다는 오류가 나요. 고치려면 @-시길 변수에 할당하거나, 핸들을 닫기 전에 $lines에 .elems를 호출하면 돼요:
my $fh = "/tmp/bar".IO.open;
my @lines = $fh.lines;
close $fh;
say @lines[0]; # no problem!
reification을 부수 효과로 하는 어떤 함수(위에서 언급한 .elems 같은)를 써도 돼요:
my $fh = "/tmp/bar".IO.open;
my $lines = $fh.lines;
say "Read $lines.elems() lines"; # reifying before closing handle
close $fh;
say $lines[0]; # no problem!
eager를 쓰면 전체 시퀀스가 구체화돼요:
my $fh = "/tmp/bar".IO.open;
my $lines = eager $fh.lines; # Uses eager for reification.
close $fh;
say $lines[0];
Introspection (내부 들여다보기)
Raku처럼 introspection을 허용하는 언어에는 타입 시스템에 붙은 기능이 있어서, 개발자가 컨테이너와 값의 메타데이터에 접근할 수 있어요. 이 메타데이터는 그 값에 따라 다른 동작을 하도록 프로그램 안에서 쓸 수 있어요. 이름에서 알 수 있듯이 메타데이터는 metaclass를 통해 값이나 컨테이너에서 추출돼요.
my $any-object = "random object";
my $metadata = $any-object.HOW;
say $metadata.^mro; # OUTPUT: «((ClassHOW) (Any) (Mu))»
say $metadata.can( $metadata, "uc" ); # OUTPUT: «(uc uc)»
첫 say로 메타모델 클래스의 계층을 보여줘요. 이 경우 Metamodel::ClassHOW인데, Any에서 직접 상속해서 그 어떤 메서드도 쓸 수 있어요. 또 클래스 구조와 함수에 대한 정보를 주는 여러 역할도 믹스인해요. 그 클래스의 메서드 중 하나가 can인데, 객체가 uc(대문자화) 메서드를 쓸 수 있는지 확인할 때 써요. 당연히 쓸 수 있죠. 다만 역할이 변수에 직접 믹스인된 다른 경우엔 그렇게 당연하지 않을 수 있어요. 예를 들어 위에서 정의한 %hash-plus의 경우:
say %hash-plus.^can("last"); # OUTPUT: «(last)»
여기서는 HOW.method의 문법적 설탕인 ^method를 써서, 데이터 구조가 그 메서드에 응답하는지 확인해요. 매칭되는 메서드 이름을 보여 주는 출력이 우리가 쓸 수 있음을 증명해 주죠.
클래스 프로퍼티·메서드에 접근하고 그걸로 클래스용 테스트 데이터를 만드는 방법에 대한 클래스 introspection 문서와, metaobject protocol을 자세히 설명하는 Advent Calendar 글도 참고하면 좋아요.