반복하기

반복하기 (Iterating)

출력을 반복하는 일을 하다 보면, 어떤 자료구조든 같은 방식으로 순회할 수 있으면 얼마나 편할지 생각해본 적이 있을 거예요. Raku는 그 문제를 IteratorIterable이라는 롤(role)로 깔끔하게 해결해요. 이 문서에서 반복이 어떻게 동작하는지 설계부터 코드까지 살펴볼게요.

출처: Raku Documentation — Iterating

본문

IteratorIterable 롤 (The Iterator and Iterable roles)

Raku는 함수형 언어예요. 하지만 함수도 복잡한 자료구조를 다룰 때는 뭔가에 기대어야 해요. 특히 모든 자료구조에 같은 방식으로 적용할 수 있는 균일한 인터페이스가 필요하죠. 그런 인터페이스 중 하나를 IteratorIterable 롤이 제공해요.

Iterable 롤은 비교적 단순해요. iterator 메서드의 스텁(stub)을 제공하는데, 그 메서드가 실제로 for 같은 문장에서 사용돼요. for는 자기 앞에 있는 변수에 .iterator를 호출한 다음, 각 항목마다 블록을 한 번씩 실행해요. 배열 할당 같은 다른 메서드들도 Iterable 클래스가 같은 방식으로 동작하게 만들어요.

class DNA does Iterable {
    has $.chain;
    method new ($chain where {
                       $chain ~~ /^^ <[ACGT]>+ $$ / and
                       $chain.chars %% 3 } ) {
        self.bless( :$chain );
    }

    method iterator(DNA:D:){ $.chain.comb.rotor(3).iterator }
};

my @longer-chain =  DNA.new('ACGTACGTT');
say @longer-chain.raku;
# OUTPUT: «[("A", "C", "G"), ("T", "A", "C"), ("G", "T", "T")]␤»

say  @longer-chain».join("").join("|"); # OUTPUT: «ACG|TAC|GTT␤»

이 예시는 for.iterator를 어떻게 호출하는지 보여주는 Iterable 문서의 예시를 확장한 거예요. 만든 객체를 @longer-chain 같은 Positional 변수에 할당했을 때만 iterator 메서드가 적절한 문맥에서 호출돼요. 이 변수는 Array라서, 마지막 예시에서는 배열처럼 다뤄요.

(조금 헷갈릴 수 있는 이름의) Iterator 롤은 Iterable보다 조금 더 복잡해요. 먼저 IterationEnd라는 상수를 제공해요. 그리고 .pull-one 같은 일련의 메서드도 제공하는데, 이 메서드는 항목을 더하거나 제거하거나 건너뛰는 등 여러 문맥에서 더 세밀한 반복을 가능하게 해줘요. 사실 이 롤은 다른 모든 메서드의 기본 구현을 제공해서, 정의해야 하는 것은 pull-one 하나뿐이에요. 그리고 그 pull-one도 롤이 스텁만 제공해요. Iterable이 루프가 다룰 상위 레벨 인터페이스를 제공한다면, Iterator는 반복의 매 순간마다 호출되는 하위 레벨 함수를 제공한다고 볼 수 있어요. 앞선 예시를 이 롤로 확장해볼게요.

class DNA does Iterable does Iterator {
    has $.chain;
    has Int $!index = 0;

    method new ($chain where {
                       $chain ~~ /^^ <[ACGT]>+ $$ / and
                       $chain.chars %% 3 } ) {
        self.bless( :$chain );
    }

    method iterator( ){ self }
    method pull-one( --> Mu){
        if $!index < $.chain.chars {
            my $codon = $.chain.comb.rotor(3)[$!index div 3];
            $!index += 3;
            return $codon;
        } else {
            return IterationEnd;
        }
    }
};

my $a := DNA.new('GAATCC');
.say for $a; # OUTPUT: «(G A A)␤(T C C)␤»

여기서 DNA 클래스를 선언하면서 IteratorIterable 두 롤을 적용했어요. 이 클래스는 길이가 3의 배수이고 ACGT로만 이루어진 문자열을 담을 거예요.

이제 pull-one 메서드를 살펴볼게요. 이 메서드는 반복이 일어날 때마다 호출되므로, 지난 반복의 상태를 유지해야 해요. $!index 속성이 호출 사이에 그 상태를 유지해요. pull-one은 체인의 끝에 도달했는지 확인하고, 도달했다면 롤이 제공하는 IterationEnd 상수를 반환해요. 이 하위 레벨 인터페이스를 구현하면, 사실 Iterable 인터페이스의 구현이 더 단순해져요. 이제 반복자(iterator)가 객체 자신이 될 수 있는데, 그 객체에 pull-one을 호출해 각 멤버에 차례로 접근할 수 있기 때문이에요. 그래서 .iterator는 그냥 self를 반환하면 돼요. 객체가 동시에 Iterable이자 Iterator이기 때문에 가능한 일이에요.

항상 이렇게만 되는 건 아니에요. 대부분의 경우 .iterator는 반환할 반복자 타입을 (예를 들어 반복 상태를 추적하도록, 지금은 메인 클래스에서 하고 있는 그 일을) 직접 만들어야 해요. 앞선 예시가 바로 그런 경우였죠. 다만 이번 예시는 반복자와 반복 가능 롤을 충족하는 클래스를 만드는 데 필요한 최소 코드를 보여줘요.

반복하는 방법: 문맥화와 주제 변수 (How to iterate: contextualizing and topic variables)

for를 비롯한 반복문은 매 반복에서 만들어지는 항목을 주제 변수(topic variable) $_에 넣거나, 블록과 함께 선언된 변수에 담아요. 이런 변수들은 ^ 트으질(twigil)을 쓰면 선언 없이 루프 안에서 바로 사용할 수 있어요.

시퀀스 연산자를 쓰면 **암묵적 반복(implicit iteration)**이 일어나요.

say 1,1,1, { $^a²+2*$^b+$^c } … * > 300; # OUTPUT: «(1 1 1 4 7 16 46 127 475)

생성 블록은 시퀀스를 끝내는 조건, 여기서는 항이 300보다 커지는 조건이 충족될 때까지 한 번씩 실행돼요. 이렇게 하면 반복문이 돌면서 동시에 출력되는 리스트가 만들어지는 부수 효과가 있어요.

이걸 더 체계적으로 하려면 gather/take 블록을 쓸 수 있어요. 이건 다른 종류의 반복 구성으로, sink 문맥에서 실행되는 대신 반복마다 항목 하나를 반환해요. 이런 루프의 용례는 Advent Calendar 튜토리얼에서 설명해요. 사실 gather는 그리 반복문 구성이라기보다 **문장 접두사(statement prefix)**에 가까워요. take가 만든 항목들을 모아 리스트로 만들어주는 역할을 하죠.

Classic 루프와 우리가 그것을 좋아하지 않는 이유

카운터 변수를 증가시키는 고전적인 for 루프도 Raku에서는 loop 키워드로 쓸 수 있어요. repeatwhile 루프도 가능해요.

하지만 일반적으로 권장되지는 않아요. Raku는 함수형이면서 동시성(concurrent) 언어예요. Raku로 코딩할 때는 루프를 함수형 관점에서 바라봐야 해요. 반복자가 만드는 항목을 하나씩 처리하고, 즉 어떤 부수 효과 없이 항목을 블록에 공급하는 방식이죠. 이 함수형 관점은 hyperrace 같은 자동 스레딩 메서드를 통한 병렬화도 쉽게 만들어줘요.

익숙한 옛 방식의 루프가 더 편하다고 느낀다면, 언어가 그걸 쓸 수 있게 해줘요. 다만 Raku에서는 가능할 때마다 함수형이면서 동시성 있는 반복 구성을 쓰는 것이 더 좋은 관행으로 여겨져요.

참고: 6.d 버전부터 루프는 마지막 문장들의 값으로부터 값 리스트를 만들어낼 수 있어요.