Raku 클래스 튜토리얼

Raku 클래스 튜토리얼 (Classes and Objects)

Raku는 클래스를 정의하고 사용하기 위한 풍부한 내장 문법을 제공해요. 대부분의 경우 클래스를 짧고 표현력 있게 쓸 수 있게 해 주면서도, 드문 엣지 케이스를 다루기 위한 메커니즘도 함께 갖추고 있습니다.

출처: Raku Docs - Classes and Objects (classtut)

빠른 개요 (A quick overview)

전체 그림을 보여주는 예시부터 시작할게요.

class Rectangle {
    has Int $.length = 1;
    has Int $.width  = 1;

    method area(--> Int) {
        return $!length * $!width;
    }
}

my $r1 = Rectangle.new(length => 2, width => 3);
say $r1.area(); # OUTPUT: «6␤»

class 키워드로 새 Rectangle 클래스를 정의했어요. has 키워드로 도입한 $!length$!width라는 두 속성(attribute)이 있고, 둘 다 기본값이 1이에요. 읽기 전용 접근자(accessor) 메서드는 자동으로 생성됩니다. (선언에서 ! 대신 .을 쓴 걸 볼 수 있는데, 바로 그게 접근자 생성의 신호예요. 기억법으로, !는 닫힌 문처럼 보이고 .은 열린 문처럼 보인다고 생각하면 돼요.)

이름이 areamethod는 사각형의 넓이를 반환합니다.

생성자를 명시적으로 작성해야 할 일은 거의 없어요. 자동으로 상속되는 기본 생성자 new가, 생성자에 넘겨진 이름 붙은 파라미터로부터 속성을 자동으로 초기화해 줍니다.

Task 예시 (The Task example)

좀 더 정교한 예로, 다음 코드는 의존성 처리기(dependency handler)를 구현해요. 커스텀 생성자, private·public 속성, 메서드, 그리고 시그니처의 여러 측면을 보여줍니다. 코드가 많지 않은데도 결과는 흥미롭고 유용해요. 이후 섹션에서 이 예시를 계속 사용할 거예요.

class Task {
    has      &!callback     is built;
    has Task @!dependencies is built;
    has Bool $.done;

    method new(&callback, *@dependencies) {
        return self.bless(:&callback, :@dependencies);
    }

    method add-dependency(Task $dependency) {
        push @!dependencies, $dependency;
    }

    method perform() {
        unless $!done {
            .perform() for @!dependencies;
            &!callback();
            $!done = True;
        }
    }
}

my $eat =
    Task.new({ say 'eating dinner. NOM!' },
        Task.new({ say 'making dinner' },
            Task.new({ say 'buying food' },
                Task.new({ say 'making some money' }),
                Task.new({ say 'going to the store' })
            ),
            Task.new({ say 'cleaning kitchen' })
        )
    );

$eat.perform();

클래스 (Class)

Raku는 다른 많은 언어처럼 class 키워드로 클래스를 정의해요. 뒤따르는 블록은 다른 어떤 블록과도 같게 임의의 코드를 담을 수 있지만, 클래스는 보통 상태(state)와 동작(behavior) 선언을 담습니다. 예시 코드에는 has 키워드로 도입하는 속성(상태)과 method 키워드로 도입하는 동작이 포함되어 있어요.

속성 (Attributes)

Task 클래스에서 블록 안의 처음 세 줄은 모두 has 선언자로 속성을 선언해요(다른 언어에서는 *필드(field)*나 *인스턴스 저장 공간(instance storage)*이라고 부르죠). my 변수가 자신이 선언된 스코프 밖에서 접근할 수 없듯이, 속성도 클래스 밖에서는 직접 접근할 수 없어요. 이건 다른 많은 언어와 대조되는 점입니다. 이런 *캡슐화(encapsulation)*는 객체 지향 설계의 핵심 원칙 중 하나예요.

트윈질 $! (Twigil $!)

첫 번째 선언은 콜백(즉, 객체가 나타내는 작업을 수행하기 위해 호출할 코드 조각)을 위한 인스턴스 저장 공간을 지정해요.

has &!callback is built;

& 시길은 이 속성이 호출 가능한 무언가를 나타낸다는 뜻이에요. ! 문자는 트윈질(twigil), 즉 2차 시길입니다. 트윈질은 변수 이름의 일부를 이룹니다. 여기서 ! 트윈질은 이 속성이 클래스에 private하다는 점을 강조해요. 속성은 캡슐화되어 있고요. 기본 생성자는 기본적으로 private 속성을 설정하지 않기 때문에, 그걸 허용하려고 is built 특질을 붙인 거예요. 기억법: !는 닫힌 문처럼 보입니다.

두 번째 선언도 private 트윈질을 사용해요.

has Task @!dependencies is built;

다만 이 속성은 항목들의 배열을 나타내므로 @ 시길이 필요해요. 이 항목들은 각각 현재 작업이 완료되기 전에 완료되어야 하는 작업을 지정합니다. 게다가 이 속성의 타입 선언은 그 배열이 Task 클래스(또는 그 서브클래스)의 인스턴스만 담을 수 있다는 뜻이에요.

트윈질 $. (Twigil $.)

세 번째 속성은 작업의 완료 상태를 나타내요.

has Bool $.done;

이 스칼라 속성($ 시길)의 타입은 Bool이에요. ! 트윈질 대신 . 트윈질을 썼죠. Raku가 속성에 캡슐화를 적용하긴 하지만, 접근자 메서드를 직접 쓰는 수고도 덜어 줍니다. !.으로 바꾸면 private 속성과 그 속성 이름을 딴 접근자 메서드가 둘 다 선언돼요. 이 경우 $!done 속성과 done 접근자 메서드가 모두 선언됩니다. 마치 이렇게 쓴 것과 같아요.

has Bool $!done;
method done() { return $!done }

이건 어떤 언어가 허용하는 'public 속성'을 선언하는 것과는 달라요. 여러분은 접근자 메서드를 손으로 쓸 필요 없이 private 속성과 메서드 둘 다를 진짜로 얻게 됩니다. 나중에 값 반환보다 더 복잡한 일을 해야 한다면, 직접 접근자 메서드를 쓰는 자유도 그대로 있습니다.

is rw 특질 (is rw trait)

. 트윈질을 쓰면 속성에 대한 읽기 전용 접근을 제공하는 메서드가 만들어진다는 점을 기억하세요. 만약 이 객체 사용자가 작업의 완료 상태를 (어쩌면 다시 수행하려고) 재설정할 수 있어야 한다면, 속성 선언을 이렇게 바꾸면 됩니다.

has Bool $.done is rw;

is rw 특질은 생성된 접근자 메서드가 컨테이너를 반환하게 해서, 외부 코드가 속성의 값을 수정할 수 있게 합니다.

is built 특질 (is built trait)

has &!callback is built;

기본적으로 private 속성은 기본 생성자가 자동으로 설정하지 않아요. (어쨌든 private이니까요.) 위 예시에서 우리는 사용자가 초기 값을 제공할 수는 있게 하되, 속성 자체는 그 외에 private하게 유지하고 싶어요. is built 특질이 바로 그걸 가능하게 해 줍니다.

public 속성에 대해서는 같은 is built 특질을 반대 용도로 쓸 수 있어요. 즉 사용자가 제공한 값으로 자동 초기화되는 것을 막고, 접근자 메서드만 생성하는 거죠. 특질에 False 인자를 주면 됩니다.

has $.done is built(False);

위 선언은 완료된 작업을 생성할 수 없게 만들면서도, 사용자가 작업이 완료됐는지 여부는 볼 수 있게 합니다.

is built 특질은 Rakudo 2020.01 릴리스에서 도입되었어요.

is required 특질 (is required trait)

초기화 중에 속성 값을 제공하는 것은 기본적으로 선택이에요. Task 예시에서는 &!callback, @!dependencies, $.done 속성 모두에게 그게 합리적입니다. 그런데 $.name이라는, 작업 이름을 담고 초기화 시 값을 반드시 제공하도록 강제하고 싶은 속성을 추가한다고 해볼게요. 이렇게 하면 됩니다.

has $.name is required;

기본값 (Default values)

속성에 기본값을 줄 수도 있어요. (접근자가 있든 없든 똑같이 동작합니다.)

has Bool $.done = False;

할당은 객체 생성(build) 시점에 수행돼요. 오른쪽은 그때 평가되며, 앞선 속성을 참조할 수도 있습니다.

has Task @!dependencies;
has $.ready = not @!dependencies;

쓰기 가능한 속성은 쓰기 가능한 컨테이너를 통해 접근할 수 있어요.

class a-class {
    has $.an-attribute is rw;
}
say (a-class.new.an-attribute = "hey"); # OUTPUT: «hey␤»

이 속성은 .an-attribute 또는 .an-attribute() 문법으로도 접근할 수 있어요. 클래스 전체에 어떻게 적용되는지 예를 보려면 클래스의 is rw 특질을 참고하세요.

클래스 변수 (Class variables)

클래스 선언은 myour로 선언된 *클래스 변수(class variables)*도 포함할 수 있어요. 클래스 변수는 값이 모든 인스턴스에 공유되는 변수로, 인스턴스 생성 횟수를 세거나 다른 공유 상태를 다루는 데 쓸 수 있습니다. 즉 클래스 변수는 다른 언어에서 알려진 static 변수처럼 동작해요. 일반적인(비클래스) 렉시컬 변수와 똑같이 보입니다. (실제로 똑같은 것이고요.)

class Str-with-ID is Str {
    my  $counter = 0;
    our $our-counter = 0;
    has Str $.string;
    has Int $.ID is built(False);

    submethod TWEAK() {
        $counter++;
        $our-counter++;
        $!ID = $counter;
    }

}

class Str-with-ID-and-tag is Str-with-ID {
    has Str $.tag;
}

say Str-with-ID.new(string => 'First').ID;  # OUTPUT: «1␤»
say Str-with-ID.new(string => 'Second').ID; # OUTPUT: «2␤»
say Str-with-ID-and-tag.new( string => 'Third', tag => 'Ordinal' ).ID; # OUTPUT: «3␤»
say $Str-with-ID::our-counter;        # OUTPUT: «3␤»

클래스 변수는 모든 서브클래스에 공유돼요. 이 경우 Str-with-ID-and-tag도 포함이죠. 게다가 패키지 스코프의 our 선언자를 쓰면 변수가 완전한 이름(FQN)으로 보이고, 렉시컬 스코프의 my 변수는 "private"해요. 이것은 myour가 비클래스 컨텍스트에서 보여주는 동작과 정확히 같습니다.

클래스 변수는 다른 많은 프로그래밍 언어의 static 변수처럼 동작해요.

class Singleton {
    my Singleton $instance;
    method new {!!!}
    submethod instance {
        $instance = Singleton.bless unless $instance;
        $instance;
    }
}

Singleton 패턴 구현에서는 클래스 변수로 인스턴스를 저장합니다.

class HaveStaticAttr {
    my Int $.foo = 5;
}

클래스 속성도 인스턴스 속성과 비슷하게 2차 시질로 선언할 수 있는데, 속성이 public이면 읽기 전용 접근자를 생성해 줘요. 기본값은 예상대로 동작하며 한 번만 할당됩니다.

메서드 (Methods)

속성이 객체에 상태를 주는 반면, 메서드는 객체에 동작을 줘요. Task 예시로 돌아가 보죠. new 메서드는 특별한 종류의 메서드이니 잠시 무시하고, 작업의 의존성 목록에 새 작업을 추가하는 두 번째 메서드 add-dependency를 살펴볼게요.

method add-dependency(Task $dependency) {
    push @!dependencies, $dependency;
}

여러모로 sub 선언과 많이 닮았어요. 하지만 중요한 차이 두 가지가 있어요. 첫째, 이 루틴을 method로 선언하면 현재 클래스의 메서드 목록에 추가되어, Task 클래스의 어떤 인스턴스든 . 메서드 호출 연산자로 그 메서드를 호출할 수 있어요. 둘째, 메서드는 자신의 invocant(호출 대상)를 특별한 변수 self에 넣습니다.

이 메서드는 넘겨받은 파라미터(반드시 Task 클래스의 인스턴스여야 해요)를 invocant의 @!dependencies 속성에 push합니다.

perform 메서드가 의존성 처리기의 메인 로직을 담고 있어요.

method perform() {
    unless $!done {
        .perform() for @!dependencies;
        &!callback();
        $!done = True;
    }
}

파라미터는 없고, 대신 객체의 속성으로 작업해요. 먼저 $!done 속성을 확인해서 작업이 이미 완료됐는지 봅니다. 완료됐다면 할 일이 없죠.

그렇지 않으면, @!dependencies 속성의 모든 항목을 순회하기 위해 for 구문을 써서 작업의 모든 의존성을 수행합니다. 이 순회는 각 항목(각각 Task 객체)을 토픽 변수 $_ 안에 넣어요. 명시적 invocant 없이 . 메서드 호출 연산자를 쓰면 현재 토픽이 invocant가 됩니다. 그래서 이 순회 구문은 현재 invocant의 @!dependencies 속성 안의 모든 Task 객체에 대해 .perform() 메서드를 호출해요.

모든 의존성이 완료된 뒤에는, &!callback 속성을 직접 호출해서 현재 Task의 작업을 수행할 차례예요. 괄호가 바로 그런 용도입니다. 마지막으로 이 메서드는 $!done 속성을 True로 설정해서, 이후 이 객체에 대한 perform 호출(예를 들어 이 Task가 다른 Task의 의존성인 경우)이 작업을 반복하지 않도록 합니다.

Private 메서드 (Private methods)

속성과 마찬가지로 메서드도 private할 수 있어요. private 메서드는 느낌표 접두사로 선언합니다. 호출할 때는 self! 뒤에 메서드 이름을 붙여요. mp3 파일에서 ID3v1 메타데이터를 추출하는 MP3TagData 클래스의 다음 구현에서, 메서드 parse-data, can-read-format, trim-nulls는 private 메서드이고 나머지는 public 메서드예요.

class MP3TagData {
    has $.filename where { .IO ~~ :e };

    has Str $.title   is built(False);
    has Str $.artist  is built(False);
    has Str $.album   is built(False);
    has Str $.year    is built(False);
    has Str $.comment is built(False);
    has Int $.genre   is built(False);
    has Int $.track   is built(False);
    has Str $.version is built(False);
    has Str $.type    is built(False) = 'ID3';

    submethod TWEAK {
        with $!filename.IO.open(:r, :bin) -> $fh {
            $fh.seek(-128, SeekFromEnd);
            my $tagdata = $fh.read(128);
            self!parse-data: $tagdata;
            $fh.close;
        }
        else {
            warn "Failed to open file."
        }
    }

    method !parse-data($data) {
        if self!can-read-format($data) {
            my $offset = $data.bytes - 128;

            $!title  = self!trim-nulls: $data.subbuf($offset +  3, 30);
            $!artist = self!trim-nulls: $data.subbuf($offset + 33, 30);
            $!album  = self!trim-nulls: $data.subbuf($offset + 63, 30);
            $!year   = self!trim-nulls: $data.subbuf($offset + 93,  4);

            my Int $track-flag = $data.subbuf($offset + 97 + 28, 1).Int;
            $!track            = $data.subbuf($offset + 97 + 29, 1).Int;

            ($!version, $!comment) = $track-flag == 0 && $!track != 0
                ?? ('1.1', self!trim-nulls: $data.subbuf($offset + 97, 28))
                !! ('1.0', self!trim-nulls: $data.subbuf($offset + 97, 30));

            $!genre = $data.subbuf($offset + 97 + 30, 1).Int;
        }
    }

    method !can-read-format(Buf $data --> Bool) {
        self!trim-nulls($data.subbuf(0..2)) eq 'TAG'
    }

    method !trim-nulls(Buf $data --> Str) {
        $data.decode('utf-8').subst(/\x[0000]+/, '')
    }
}

다른 클래스의 private 메서드를 호출하려면, 호출자가 피호출자의 신뢰를 받아야 해요. 신뢰 관계는 trusts로 선언하고, 신뢰받을 클래스는 먼저 선언되어 있어야 합니다. 다른 클래스의 private 메서드를 호출하려면 그 클래스의 인스턴스와 메서드의 완전한 이름(FQN)이 필요해요. 신뢰 관계는 private 속성 접근도 허용합니다.

class B {...}

class C {
    trusts B;
    has $!hidden = 'invisible';
    method !not-yours () { say 'hidden' }
    method yours-to-use () {
        say $!hidden;
        self!not-yours();
    }
}

class B {
    method i-am-trusted () {
        my C $c.=new;
        $c!C::not-yours();
    }
}

C.new.yours-to-use(); # the context of this call is GLOBAL, and not trusted by C
B.new.i-am-trusted();

신뢰 관계는 상속의 대상이 아니에요. 전역 네임스페이스를 신뢰하려면 가상 패키지 GLOBAL을 쓸 수 있습니다.

객체 생성 (Construction)

지금까지 설명한 객체 생성 메커니즘은 대부분의 사용 사례에 충분해요. 하지만 정말로 그 메커니즘 이상의 조정이 필요하다면, 객체 생성이 좀 더 자세히 어떻게 동작하는지 이해하는 게 좋아요.

Raku는 생성자 영역에서 많은 언어보다 훨씬 자유로워요. 생성자는 클래스의 인스턴스를 반환하는 무엇이든 될 수 있거든요. 게다가 생성자는 평범한 메서드입니다. 기본 클래스 Mu에서 이름이 new인 기본 생성자를 상속하지만, Task 예시처럼 new를 오버라이드하는 것도 자유예요.

method new(&callback, *@dependencies) {
    return self.bless(:&callback, :@dependencies);
}

bless

Raku의 생성자와 C#, Java 같은 언어의 생성자의 가장 큰 차이는, 마법처럼 이미 만들어진 객체에 상태를 설정하는 대신, Raku 생성자가 객체를 스스로 만든다는 점이에요. 생성자는 Mu에서 상속하는 bless 메서드를 호출해서 그렇게 합니다. bless 메서드는 각 속성의 초기 값을 제공하는 이름 붙은 파라미터 집합을 기대해요.

예시의 생성자는 위치 인자를 이름 붙은 인자로 바꿔서, 클래스가 사용자에게 더 나은 생성자를 제공할 수 있게 해 줍니다. 첫 번째 파라미터는 콜백(작업을 실행할 것)이고, 나머지는 의존하는 Task 인스턴스들이에요. 생성자는 이것들을 슬러피 배열 @dependencies에 담아 이름 붙은 파라미터로 bless에 넘깁니다(:&callback이 변수 이름에서 시길을 뺀 것을 파라미터 이름으로 쓴다는 점에 주목하세요). 생성자에는 파라미터를 재구성하는 것 외의 로직을 넣지 않는 게 좋아요. 생성자 메서드는 부모 클래스에 대해 재귀적으로 호출되지 않으니까요. 이건 예를 들어 Java와는 다른 점입니다.

newmethod로 (그리고 multi method가 아니라) 선언하면 기본 생성자에 대한 접근이 차단돼요. 그래서 기본 생성자를 계속 쓸 수 있게 하려면 multi method new를 사용하세요.

TWEAK

bless가 넘겨받은 값으로 클래스의 속성을 초기화한 뒤에는, 상속 계층의 각 클래스에 대해 TWEAK을 차례로 호출합니다. TWEAK은 bless에 넘겨진 모든 인자를 받아요. 커스텀 초기화 로직은 여기 넣으면 됩니다.

TWEAK은 항상 일반 method가 아니라 submethod로 만들어야 한다는 걸 잊지 마세요. 클래스 계층에서 어떤 클래스가 TWEAK 메서드를 (submethod 대신 method로) 담고 있다면, 그 메서드는 서브클래스로 상속되어 서브클래스 생성 중에 두 번 호출될 수 있어요!

BUILD

자동 속성 초기화를 끄고 속성 초기화를 직접 수행하는 것도 가능해요. 그러려면 커스텀 BUILD submethod를 작성하면 됩니다. 다만 알아두고 고려해야 할 엣지 케이스가 여러 개 있어요. 이 내용은 객체 생성 참고 문서에 자세히 나와요. BUILD는 사용하기 어렵기 때문에, 위에서 설명한 다른 접근법으로 충분하지 않을 때만 쓰는 게 권장됩니다.

소멸 (Destruction)

Raku는 가비지 컬렉팅 언어예요. 즉 보통 객체 정리를 신경 쓸 필요가 없다는 뜻입니다. Raku가 알아서 해 주니까요. 다만 Raku가 특정 객체를 언제 정리할지에 대한 보장은 없어요. 보통 런타임이 메모리를 필요로 할 때만 정리를 수행하기 때문에, 언제 일어날지 믿을 수 없습니다.

객체가 정리될 때 커스텀 코드를 실행하려면 DESTROY submethod를 쓰면 돼요. 예를 들어 더 이상 쓰지 않을 핸들이나 공급(supply)을 닫거나, 임시 파일을 삭제하는 데 쓸 수 있습니다. 가비지 컬렉션은 프로그램 실행 중 임의의 시점, 심지어 다른 스레드의 완전히 무관한 코드 조각 도중에도 일어날 수 있으므로, DESTROY submethod에서 어떤 컨텍스트도 가정하지 않아야 해요.

my $in_destructor = 0;

class Foo {
    submethod DESTROY { $in_destructor++ }
}

my $foo;
for 1 .. 6000 {
    $foo = Foo.new();
}

say "DESTROY called $in_destructor times";

이것은 DESTROY called 5701 times 같은 걸 출력할 수 있고, 어쩌면 이전 Foo 인스턴스 몇천 개를 밟고 나서야 발동할 수도 있어요. 소멸 순서도 믿을 수 없습니다.

TWEAK과 마찬가지로, DESTROY도 항상 submethod로 선언하세요.

우리 클래스 사용하기 (Consuming our class)

클래스를 만든 뒤에는 클래스의 인스턴스를 만들 수 있어요. 커스텀 생성자를 선언하면 작업을 의존성과 함께 쉽게 선언할 수 있습니다. 의존성이 없는 단일 작업을 만들려면 이렇게 쓰면 돼요.

my $eat = Task.new({ say 'eating dinner. NOM!' });

앞서 설명했듯이 Task 클래스를 선언하면 네임스페이스에 타입 객체가 설치돼요. 이 타입 객체는 클래스의 일종의 "빈 인스턴스", 정확히는 상태가 없는 인스턴스입니다. 그 인스턴스에서 상태에 접근하지 않는 메서드는 호출할 수 있어요. new가 그 예인데, 기존 객체를 수정하거나 접근하는 게 아니라 새 객체를 만들기 때문이에요.

안타깝게도 저녁은 마법처럼 저절로 일어나지 않아요. 의존 작업이 있거든요.

my $eat =
    Task.new({ say 'eating dinner. NOM!' },
        Task.new({ say 'making dinner' },
            Task.new({ say 'buying food' },
                Task.new({ say 'making some money' }),
                Task.new({ say 'going to the store' })
            ),
            Task.new({ say 'cleaning kitchen' })
        )
    );

커스텀 생성자와 공백의 현명한 사용이 작업 의존성을 얼마나 명확하게 만드는지 보세요.

마지막으로, perform 메서드 호출은 여러 다른 의존성에 대해 perform 메서드를 순서대로 재귀 호출해서 다음 출력을 냅니다.

making some money
going to the store
buying food
cleaning kitchen
making dinner
eating dinner. NOM!

타입에 대해 한마디 (A word on types)

클래스를 선언하면 새 *타입 객체(type object)*가 만들어지는데, 기본적으로 현재 패키지에 설치돼요(our 스코프로 선언한 변수처럼요). 이 타입 객체는 클래스의 "빈 인스턴스"입니다. 예를 들어 IntStr 같은 타입은 Raku 내장 클래스 중 하나의 타입 객체를 가리켜요. 이런 타입 객체에서 메서드를 호출할 수 있습니다. 그래서 타입 객체에서 new 메서드를 호출하는 건 전혀 특별한 일이 아니에요.

.DEFINITE 메서드로 지금 가진 게 인스턴스인지 타입 객체인지 알 수 있어요.

say Int.DEFINITE; # OUTPUT: «False␤» (type object)
say 426.DEFINITE; # OUTPUT: «True␤»  (instance)

class Foo {};
say Foo.DEFINITE;     # OUTPUT: «False␤» (type object)
say Foo.new.DEFINITE; # OUTPUT: «True␤»  (instance)

함수 시그니처에서는 소위 타입 "스마일리"(smiley)를 써서 인스턴스나 타입 객체만 받을 수 있어요.

multi foo (Int:U) { "It's a type object!" }
multi foo (Int:D) { "It's an instance!"   }
say foo Int; # OUTPUT: «It's a type object!␤»
say foo 42;  # OUTPUT: «It's an instance!␤»

상속 (Inheritance)

객체 지향 프로그래밍은 코드 재사용 메커니즘 중 하나로 상속 개념을 제공해요. Raku는 한 클래스가 하나 이상의 클래스를 상속받는 것을 지원합니다. 클래스가 다른 클래스를 상속받으면, 디스패치할 메서드를 찾기 위해 상속 체인을 따라가라고 메서드 디스패처에게 알려줘요. 이는 method 키워드로 정의한 표준 메서드와, 속성 접근자처럼 다른 수단으로 생성된 메서드 양쪽에 모두 적용됩니다.

class Employee {
    has $.salary;
}

class Programmer is Employee {
    has @.known_languages is rw;
    has $.favorite_editor;

    method code_to_solve( $problem ) {
        return "Solving $problem using $.favorite_editor in "
        ~ $.known_languages[0];
    }
}

이제 Programmer 타입의 어떤 객체든 Employee 클래스에 정의된 메서드와 접근자를 마치 Programmer 클래스의 것처럼 사용할 수 있어요.

my $programmer = Programmer.new(
    salary => 100_000,
    known_languages => <Raku Perl Erlang C++>,
    favorite_editor => 'vim'
);

say $programmer.code_to_solve('halting problem'),
    " will get \$ {$programmer.salary()}";
# OUTPUT: «Solving halting problem using vim in Raku will get $100000␤»

상속받은 메서드 오버라이드 (Overriding inherited methods)

클래스는 자신만의 정의를 통해 부모 클래스가 정의한 메서드와 속성을 오버라이드할 수 있어요. 아래 예시는 Baker 클래스가 Cookcook 메서드를 오버라이드하는 모습을 보여줍니다.

class Cook is Employee {
    has @.utensils  is rw;
    has @.cookbooks is rw;

    method cook( $food ) {
        say "Cooking $food";
    }

    method clean_utensils {
        say "Cleaning $_" for @.utensils;
    }
}

class Baker is Cook {
    method cook( $confection ) {
        say "Baking a tasty $confection";
    }
}

my $cook = Cook.new(
    utensils  => <spoon ladle knife pan>,
    cookbooks => 'The Joy of Cooking',
    salary    => 40000
);

$cook.cook( 'pizza' );       # OUTPUT: «Cooking pizza␤»
say $cook.utensils.raku;     # OUTPUT: «["spoon", "ladle", "knife", "pan"]␤»
say $cook.cookbooks.raku;    # OUTPUT: «["The Joy of Cooking"]␤»
say $cook.salary;            # OUTPUT: «40000␤»

my $baker = Baker.new(
    utensils  => 'self cleaning oven',
    cookbooks => "The Baker's Apprentice",
    salary    => 50000
);

$baker.cook('brioche');      # OUTPUT: «Baking a tasty brioche␤»
say $baker.utensils.raku;    # OUTPUT: «["self cleaning oven"]␤»
say $baker.cookbooks.raku;   # OUTPUT: «["The Baker's Apprentice"]␤»
say $baker.salary;           # OUTPUT: «50000␤»

디스패처가 부모 클래스로 올라가기 전에 Bakercook 메서드를 먼저 보기 때문에, Bakercook 메서드가 호출되는 거예요.

상속 체인의 메서드에 접근하려면 재디스패치(re-dispatch)MOP를 사용하세요.

다중 상속 (Multiple inheritance)

앞서 말했듯이 클래스는 여러 클래스를 상속할 수 있어요. 클래스가 여러 클래스를 상속하면 디스패처는 메서드를 찾을 때 두 클래스를 모두 봅니다. Raku는 C3 알고리즘으로 다중 상속 계층을 선형화하는데, 이는 다중 상속을 다루는 데 깊이 우선 탐색보다 더 낫습니다.

class GeekCook is Programmer is Cook {
    method new( *%params ) {
        push( %params<cookbooks>, "Cooking for Geeks" );
        return self.bless(|%params);
    }
}

my $geek = GeekCook.new(
    books           => 'Learning Raku',
    utensils        => ('stainless steel pot', 'knife', 'calibrated oven'),
    favorite_editor => 'MacVim',
    known_languages => <Raku>
);

$geek.cook('pizza');
$geek.code_to_solve('P =? NP');

이제 ProgrammerCook 클래스가 사용 가능하게 만든 모든 메서드를 GeekCook 클래스에서 쓸 수 있어요.

다중 상속은 알아 두고 가끔 쓰면 유용한 개념이지만, 더 유용한 OOP 개념들이 있다는 것도 이해하는 게 중요해요. 다중 상속을 쓸 때는, 롤(role)로 설계를 실현하는 게 더 낫지 않은지 고려해 보는 게 좋은 습관입니다. 롤은 충돌하는 메서드 이름을 클래스 작성자가 명시적으로 해결하도록 강제하기 때문에 일반적으로 더 안전해요. 롤에 대한 자세한 내용은 Roles을 참고하세요.

also

상속받을 클래스는 is 특질 앞에 also를 붙여 클래스 선언 본문에 나열할 수도 있어요. 이는 롤 합성 특질 does에도 똑같이 적용됩니다.

class GeekCook {
    also is Programmer;
    also is Cook;
    # ...
}

role A {};
role B {};
class C {
    also does A;
    also does B;
    # ...
}

인트로스펙션 (Introspection)

인트로스펙션은 소스 코드를 읽는 대신, 객체(또는 그것을 통제하는 객체)에 그 타입과 같은 어떤 속성을 질의해서 프로그램 안의 어떤 객체에 대한 정보를 모으는 과정이에요.

객체 $o와 앞 섹션들의 클래스 정의가 주어졌을 때, 몇 가지 질문을 할 수 있어요.

my Programmer $o .= new;
if $o ~~ Employee { say "It's an employee" };
say $o ~~ GeekCook ?? "It's a geeky cook" !! "Not a geeky cook";
say $o.^name;
say $o.raku;
say $o.^methods(:local)».name.join(', ');

출력은 이렇게 보일 수 있어요.

It's an employee
Not a geeky cook
Programmer
Programmer.new(known_languages => ["Perl", "Python", "Pascal"],
        favorite_editor => "gvim", salary => "too small")
code_to_solve, known_languages, favorite_editor

처음 두 테스트는 각각 클래스 이름과 스마트매치해요. 객체가 그 클래스이거나 상속하는 클래스면 True를 반환합니다. 그래서 이 객체는 Employee 클래스이거나 그걸 상속하지만, GeekCook은 아니에요.

$o.^name 호출은 $o의 타입을 알려줍니다. 여기서는 Programmer예요.

$o.raku는 Raku 코드로 실행 가능하고 원래 객체 $o를 재현하는 문자열을 반환해요. 모든 경우에 완벽하게 동작하진 않지만, 단순한 객체를 디버깅할 때 매우 유용합니다. [1]

. 대신 .^로 메서드를 호출하는 문법은 실제로 그것이 *메타클래스(metaclass)*에 대한 메서드 호출임을 뜻해요. 메타클래스는 Programmer 클래스(또는 여러분이 관심 있는 어떤 클래스든)의 속성을 관리하는 클래스입니다. 이 메타클래스는 다른 인트로스펙션 방식도 가능하게 해 줘요.

say $o.^attributes.join(', ');
say $o.^parents.map({ $_.^name }).join(', ');

마지막으로 $o.^name은 메타객체의 name 메서드를 호출하며, 놀랍게도(?) 클래스 이름을 반환합니다.

객체 $mp3Private 메서드 섹션의 MP3TagData 클래스 정의가 주어졌을 때, .^methods로 public 메서드를 물어볼 수 있어요.

my $mp3 = MP3TagData.new(filename => 'football-head.mp3');
say $mp3.^methods(:local);
# OUTPUT: (TWEAK filename title artist album year comment genre track version
# type Submethod+{is-hidden-from-backtrace}.new)

$mp3.^methods(:local)$mp3에서 호출할 수 있는 Method들의 목록을 만들어요. :local 이름 붙은 인자는 반환되는 메서드를 MP3TagData 클래스에 정의된 것으로 제한하고 상속받은 것을 배제합니다. MP3TagData는 어떤 클래스에서도 상속하지 않으므로 :local을 줘도 차이가 없어요.

타입 객체(또는 인스턴스 객체)가 특정 public 메서드를 구현했는지 확인하려면 .^find-method 메타메서드를 쓰면 돼요. 존재하면 메서드 객체를 반환하고, 없으면 Mu를 반환합니다.

say $mp3.^find_method('name');   # OUTPUT: «(Mu)␤»
say $mp3.^find_method('artist'); # OUTPUT: «artist␤»

타입 객체의 private 메서드도 인트로스펙션할 수 있어요. 다만 public과 private 메서드는 같은 API를 쓰지 않으므로 다른 메타메서드를 써야 해요: .^private_methods.^find_private_method입니다.

say $mp3.^private_methods;                     # OUTPUT: «(parse-data can-read-format trim-nulls)␤»
say $mp3.^find_private_method('parse-data');   # OUTPUT: «parse-data␤»
say $mp3.^find_private_method('remove-nulls'); # OUTPUT: «(Mu)␤»

인트로스펙션은 디버깅하고, 언어와 새 라이브러리를 배우는 데 매우 유용해요. 함수나 메서드가 알 수 없는 객체를 반환했을 때, .^name으로 타입을 찾고 .raku로 생성 레시피를 보는 식으로 진행하면 그 반환값이 무엇인지 좋은 감을 얻을 수 있습니다. .^methods로는 그 클래스로 뭘 할 수 있는지를 배우고요.

그런데 다른 응용도 있어요. 예를 들어 객체를 바이트로 직렬화하는 루틴은 그 객체의 속성을 알아야 하는데, 인트로스펙션으로 찾아낼 수 있습니다.

기본 gist 메서드 오버라이드 (Overriding default gist method)

어떤 클래스는 클래스의 기본 표현을 제공하기 위해, 호출될 때 간결하게 출력되는 방식을 오버라이드하는 자신만의 gist 버전이 필요할 수 있어요. 예를 들어 예외는 전체 객체가 아니라 payload만 써서 무슨 일이 일어났는지 더 분명하게 보여주고 싶을 수 있어요. 하지만 이건 예외에만 국한되지 않고 모든 클래스에서 할 수 있습니다.

class Cook {
    has @.utensils  is rw;
    has @.cookbooks is rw;

    method cook( $food ) {
        return "Cooking $food";
    }

    method clean_utensils {
        return "Cleaning $_" for @.utensils;
    }

    multi method gist(Cook:U:) { '⚗' ~ self.^name ~ '⚗' }
    multi method gist(Cook:D:) {
        '⚗ Cooks with ' ~ @.utensils.join( " ‣ ") ~ ' using '
          ~ @.cookbooks.map( "«" ~ * ~ "»").join( " and ") }
}

my $cook = Cook.new(
    utensils => <spoon ladle knife pan>,
    cookbooks => ['Cooking for geeks','The French Chef Cookbook']);

say Cook.gist; # OUTPUT: «⚗Cook⚗»
say $cook.gist; # OUTPUT: «⚗ Cooks with spoon ‣ ladle ‣ knife ‣ pan using «Cooking for geeks» and «The French Chef Cookbook»␤»

보통 두 메서드를 정의하고 싶을 거예요. 클래스용 하나와 클래스 인스턴스용 하나입니다. 이 경우 클래스 메서드는 연금술(alembic) 기호를 쓰고, 그 아래 정의된 인스턴스 메서드는 요리사에 대한 데이터를 모아 서술적인 방식으로 보여줍니다.

실용적인 인트로스펙션 예시 (A practical introspection example)

새 클래스를 만들 때, 유익하고(그리고 안전한) 인트로스펙션을 public 메서드로 더 쉽게 접근할 수 있게 만드는 게 유용할 때가 있어요. 예를 들어 다음 클래스는 헤더 행이 필드(속성) 이름을 정의하는 CSV 스프레드시트에서 레코드 행의 속성을 담는 데 쓰여요.

unit class CSV-Record;
#| Field names and values for a CSV row
has $last;
has $first;
#...more fields (attributes)...

method fields(--> List) {
    #| Return a list of the the attribute names (fields)
    #| of the class instance
    my @attributes = self.^attributes;
    my @names;
    for @attributes -> $a {
        my $name = $a.name;
        # The name is prefixed by its sigil and twigil
        # which we don't want
        $name ~~ s/\S\S//;
        @names.push: $name;
    }
    @names
}

method values(--> List) {
    #| Return a list of the values for the attributes
    #| of the class instance
    my @attributes = self.^attributes;
    my @values;
    for @attributes -> $a {
        # Syntax is not obvious
        my $value = $a.get_value: self;
        @values.push: $value;
    }
    @values
}

다음 내용을 가진 단순한 CSV 파일과 함께 씁니다.

last,   first #...more fields...
Wall,   Larry
Conway, Damian

첫 레코드를 불러와 내용을 보여줘요.

my $record = CSV-Record.new: :$last, :$first;
say $record.fields.raku; # OUTPUT: «["last", "first"]␤»
say $record.values.raku; # OUTPUT: «["Wall", "Larry"]␤»

실용적으로는 그 값이 모든 클래스 객체에 대해 같기 때문에 fields 목록을 상수로 갖도록 클래스를 설계했을 거예요.

constant @fields = <last first>;
method fields(--> List) {
    @fields
}

속성 이름에 인트로스펙션 메서드를 쓰는 단점은 처리 시간과 전력이 조금 더 들고, public 표현을 위해 시길과 트윈질을 제거해야 할 가능성이 있다는 점입니다.


[1] [↑] .raku