메타메서드와 메타객체
메타메서드와 메타객체 (Metamethods / Metalanguage)
Raku는 메타객체 레이어(metaobject layer) 위에 세워진 언어예요. 다시 말해 클래스, 롤, 메서드, 속성, 열거형 같은 객체지향 구성 요소들의 동작을 제어하는 객체(즉 메타객체)가 존재해요. 이 문서에서는 그 메타객체가 무엇인지, 코드에서 어떻게 접근하고 활용하는지, 그리고 메타객체 시스템이 어떤 구조로 이루어져 있는지 살펴볼게요.
본문
Raku는 메타객체 레이어 위에 세워져 있어요. 즉 클래스, 롤, 메서드, 속성, 열거형 같은 다양한 객체지향 구성 요소가 어떻게 동작하는지를 제어하는 객체(메타객체)들이 존재한다는 뜻이에요.
메타객체는 일반 객체의 타입이 필요할 때 사용자에게 실질적인 이점을 줘요. 예를 들어:
my $arr = [1, 2];
say $arr.^name; # OUTPUT: «Array»
class의 메타객체를 더 깊이 이해하기 위해, 같은 예시를 두 번 보여드릴게요. 한 번은 Raku의 일반 선언으로, 한 번은 metamodel을 통해 표현한 거예요:
class A {
method x() { say 42 }
}
A.x();
이것은 다음에 대응돼요:
constant A := Metamodel::ClassHOW.new_type( name => 'A' ); # class A {
A.^add_method('x', my method x(A:) { say 42 }); # method x()
A.^compose; # }
A.x();
(단, 선언형 형태는 컴파일 타임에 실행되고, 후자 형태는 컴파일 타임에 실행되지 않는다는 차이가 있어요.)
객체 뒤의 메타객체는 $obj.HOW로 얻을 수 있어요. 여기서 HOW는 Higher Order Workings의 약자예요 (혹은 HOW the *%@$ does this work? — "이게 대체 어떻게 동작하는 거지?").
여기서 .^를 붙인 호출은 메타객체에 대한 호출이에요. 그러니까 A.^compose는 A.HOW.compose(A)의 단축형이에요. 호출자(invocant)가 매개변수 목록에도 함께 전달되는데, 그 이유는 프로토타입 스타일의 타입 시스템을 지원하기 위해서예요. 그런 시스템에서는 타입마다 하나씩이 아니라 메타객체가 하나뿐이거든요 (표준 Raku는 타입마다 하나씩이에요).
위 예시가 보여주듯, 모든 객체지향 기능은 컴파일러뿐 아니라 사용자에게도 열려 있어요. 사실 컴파일러도 그런 메타객체 호출을 그냥 사용할 뿐이에요.
메타메서드 (Metamethods)
메타메서드는 메서드 호출처럼 생긴 내성적(introspective) 매크로예요.
메타메서드는 일반적으로 모두 대문자(ALLCAPS)로 이름을 지어요. 그래서 대문자 이름의 메서드를 직접 만드는 것은 피하는 게 좋은 스타일로 여겨져요 — 대문자 이름은 phaser 같은 것에 관례적으로 쓰이거든요. 이렇게 해야 미래 언어 버전에 등장할 메타메서드와 충돌하는 일을 피할 수 있어요.
메타메서드와 같은 이름의 메서드를 정의해도 그 메타메서드의 기능은 숨겨지지 않아요. 왜냐하면 메타메서드는 문법(grammar)에서 특별하게 처리되기 때문이에요.
WHAT
값의 타입 객체(type object)예요. 예를 들어 42.WHAT는 Int 타입 객체를 반환해요.
WHICH
객체의 동일성(identity) 값이에요. 해싱과 동일성 비교에 쓰이며, === 중위 연산자가 바로 이 방식으로 구현돼요.
WHO
객체를 지원하는 패키지예요.
WHERE
객체의 메모리 주소예요. 이 주소는 이동/압축 가비지 컬렉터를 쓰는 구현에서는 안정적이지 않다는 점에 주의하세요. 안정적인 동일성 표시가 필요하면 WHICH를 사용해요.
HOW
"Higher Order Workings"라는 뜻으로, 메타클래스 객체를 반환해요.
say (%).HOW.^name # OUTPUT: «Perl6::Metamodel::ClassHOW+{<anon>}»
이 경우 HOW는 Perl6::Metamodel::ClassHOW 타입의 객체를 반환해요. 이 타입의 객체는 클래스를 만드는 데 사용돼요. & 시길에 같은 연산을 하면 Perl6::Metamodel::ParametricRoleGroupHOW를 반환해요. ^ 문법으로 메타메서드에 접근할 때마다 이 객체를 호출하는 셈이에요. 사실 위 코드는 say (&).HOW.HOW.name(&)과 동일한데, 후자는 훨씬 다루기 힘들어요. Metamodel::ClassHOW는 Rakudo 구현의 일부이므로 주의해서 사용하세요.
WHY
부착된 Pod 값이에요.
DEFINITE
객체가 유효한 구체적(concrete) 표현을 가지는지 여부예요. 인스턴스(예를 들어 "defined" 메서드가 오버로드된 Failure 객체라도)에 대해서는 True를, 타입 객체에 대해서는 False를 반환해요.
VAR
기저에 있는 Scalar 객체가 있다면 그것을 반환해요.
Scalar 객체가 존재한다는 것은 그 객체가 "아이템화(itemized)" 되어 있다는 뜻이에요.
.say for (1, 2, 3); # OUTPUT: «123», not itemized
.say for $(1, 2, 3); # OUTPUT: «(1 2 3)», itemized
say (1, 2, 3).VAR ~~ Scalar; # OUTPUT: «False»
say $(1, 2, 3).VAR ~~ Scalar; # OUTPUT: «True»
자세한 내용은 item context 섹션을 참고하세요.
메타클래스 메서드 (Metaclass methods)
객체와 클래스 메서드(인스턴스 변수에 접근하지 못하는 메서드)를 정의할 수 있듯이, 메타클래스에서 동작하는 메타클래스 메서드도 정의할 수 있어요. 관례적으로 메서드 식별자 앞에 캐럿(^)을 붙여요. 이 메타클래스 메서드는 타입 객체나 단순 객체를 반환할 수 있는데, 일반적으로 메타객체 프로토콜과 관례적으로만 관련되고, 그 외에는 특이한 문법을 가진 단순 메서드예요.
이 메서드들은 첫째 인자로 타입 이름을 받아 호출되는데, 이를 명시적으로 선언해야 해요.
class Foo {
method ^bar( Mu \foo) {
foo.^set_name( foo.^name ~ "[þ]" );
}
}
my $foo = Foo.new();
say $foo.^name; # OUTPUT: «Foo»
Foo.^bar();
say $foo.^name; # OUTPUT: «Foo[þ]»
이 메타클래스 메서드는 클래스 메타메서드를 호출해서, 자신이 선언된 클래스의 이름을 변경해요. 이것이 메타클래스에 작용했으므로, 같은 클래스의 어떤 새 객체든 같은 이름을 받게 돼요. say Foo.new().^name을 호출해도 같은 값이 반환돼요. 보시다시피 메타클래스 메서드는 인자 없이 호출되는데, 이 경우 호출될 때 \foo가 Foo가 돼요.
메타클래스 메서드는 원하는 만큼 많은 인자를 받을 수 있어요.
class Foo {
method ^bar( Mu \foo, Str $addenda) {
foo.^set_name( foo.^name ~ $addenda );
}
}
Foo.new().^bar( "[baz]" );
my $foo = Foo.new();
say $foo.^name; # OUTPUT: «Foo[baz]»
다시, 암시적으로 메서드 호출이 첫 인자인 타입 객체를 제공해요. 이들은 메타클래스 메서드이므로 클래스에(위처럼) 호출할 수도, 객체에(아래처럼) 호출할 수도 있어요. 결과는 정확히 같아요.
메타객체 시스템의 구조 (Structure of the metaobject system)
참고: 이 문서는 대부분 Rakudo Raku 컴파일러가 구현한 메타객체 시스템을 반영해요. 설계 문서는 세부 사항이 매우 부실하기 때문이에요.
class, role, enum, module, package, grammar, subset 같은 각 타입 선언 키워드마다 Metamodel:: 네임스페이스에 별도의 메타클래스가 있어요. (Rakudo는 Perl6::Metamodel:: 네임스페이스에 구현한 뒤 Perl6::Metamodel을 Metamodel로 매핑해요.)
이 메타클래스들은 공통 기능을 많이 공유해요. 예를 들어 롤, 그라마, 클래스는 모두 메서드와 속성을 포함할 수 있고 롤을 수행(do)할 수도 있어요. 이런 공유 기능은 적절한 메타클래스에 합성(compose)되는 롤로 구현돼요. 예를 들어 Metamodel::RoleContainer 롤은 "타입이 롤을 담을 수 있는" 기능을 구현하고, class 키워드 뒤의 메타클래스인 Metamodel::ClassHOW가 이 롤을 수행해요.
대부분의 메타클래스는 메타객체 생성·수정을 끝냈을 때 호출해야 하는 compose 메서드를 가져요. 이 메서드는 메서드 캐시를 만들고, 검증 등을 수행해요. 이걸 깜빡하면 이상한 동작이 일어나니 꼭 호출하세요 :-).
부트스트래핑 고려사항 (Bootstrapping concerns)
Metamodel::ClassHOW가 어떻게 "클래스인 것" 자체가 Metamodel::ClassHOW로 정의되는데도 클래스일 수 있는지, 혹은 롤 처리 담당 롤이 어떻게 롤일 수 있는지 궁금할 수 있어요. 답은 마법 이에요.
농담이에요. 부트스트래핑은 구현별로 달라요. Rakudo는 자기 자신이 구현된 언어의 객체 시스템을 사용하는데, 그 언어가 우연히도 Raku의 (거의) 부분집합인 NQP이에요. NQP에는 knowhow라는 클래스와 비슷한 원시 형태가 있는데, 자기 자신의 클래스와 롤 구현을 부트스트랩하는 데 쓰여요. knowhow는 NQP 아래의 가상머신이 제공하는 원시 기능 위에 지어져 있어요.
객체 모델이 더 낮은 수준의 타입으로 부트스트랩되기 때문에, 내성(introspection)이 기대한 타입 대신 저수준 타입을 반환할 때가 있어요. 예를 들어 일반 Routine 객체 대신 NQP 수준의 루틴을, Attribute 대신 부트스트랩 속성을 반환하는 식이에요.
합성 시점과 정적 추론 (Composition time and static reasoning)
Raku에서 타입은 파싱되는 대로 만들어지므로, 처음에는 가변(mutable)이어야 해요. 하지만 모든 타입이 항상 가변이라면, 타입에 대한 모든 추론은 타입이 수정될 때마다 무효화돼요. 예를 들어 부모 타입 목록, 따라서 타입 검사 결과도 그 시점에 바뀔 수 있어요.
그래서 두 가지 장점을 모두 얻기 위해, 타입이 가변에서 불변(immutable)으로 전환되는 시점이 있어요. 이를 합성(composition)이라고 불러요. 문법으로 선언된 타입의 경우 타입 선언이 완전히 파싱될 때(보통 닫는 중괄호를 파싱할 때) 발생해요.
메타객체 시스템을 통해 직접 타입을 만든다면, 완전히 기능하기 전에 .^compose를 호출해야 해요.
대부분의 메타클래스는 합성 시점을 이용해 메서드 해석 순서(method resolution order) 같은 속성을 계산하고, 메서드 캐시를 발행하며, 기타 유지보수 작업을 해요. 합성된 타입을 그 후에 건드리는 것은 때로는 가능하지만 대개 재앙의 지름길이에요. 하지 마세요.
권력과 책임 (Power and responsibility)
메타객체 프로토콜은 일반 Raku 코드가 의도적으로 제한하는 많은 권력을 제공해요. 예를 들어 자기한테 신뢰하지 않는(private) 메서드를 호출하거나, 비공개(private) 속성을 들여다보거나, 평소에는 그냥 하지 않는 일들 말이에요.
일반 Raku 코드에는 안전 검사가 많이 갖춰져 있어요. 하지만 메타모델은 다르죠. 메타모델은 가상머신에 가깝기 때문에, VM과의 계약을 위반하면 일반 코드에서는 명백히 버그로 보이는 온갖 이상한 동작이 일어날 수 있어요.
그러니 메타타입을 작성할 때는 각별히 주의하고 신중하게 생각하세요.
권력, 편의성, 함정 (Power, convenience and pitfalls)
메타객체 프로토콜은 Raku 객체 시스템을 구현할 수 있을 만큼 강력하도록 설계됐어요. 이 강력함은 때로 편의성을 희생하고 얻어져요.
예를 들어 my $x = 42라고 쓰고 나서 $x에 메서드를 호출하면, 대부분의 메서드는 결국 그 값이 저장된 스칼라 컨테이너가 아니라 정수 42에 작용해요. 이것은 일반 Raku에서 찾을 수 있는 편의성의 일부예요. 메타객체 프로토콜의 많은 부분은 스칼라 컨테이너를 자동으로 무시하는 편의를 제공할 수 없어요. 왜냐하면 그 프로토콜 자체가 스칼라 컨테이너를 구현하는 데 쓰이기 때문이에요. 그래서 my $t = MyType; ...; $t.^compose라고 쓰면, 여러분은 $ 시길 변수가 암시하는 Scalar를 합성하는 것이지 MyType을 합성하는 게 아니에요.
그 결과, MOP로 작업할 때 함정을 피하려면 Raku의 미묘함에 대한 상당히 자세한 이해가 필요해요. 일반 Raku 코드가 제공하는 "내가 뜻하는 대로 해줘(DWIM)" 편의는 같을 거라고 기대할 수 없어요.
원형 (Archetypes)
여러 종류의 타입이 속성을 공유할 때는 보통 그 속성을 메타롤(metarole)로 구현해 메타클래스에 혼합(mixin)해요. 하지만 모든 공통 속성을 믹스인으로 구현할 수는 없어요. 어떤 속성은 다양한 종류의 타입에 공통이지만, 믹스인으로 구현하기에는 동작을 충분히 공유하지 못해요. 이런 속성을 원형(archetypes)이라고 불러요.
HOW들은 인자를 받지 않고 Metamodel::Archetypes 인스턴스를 반환하는 archetypes 메타메서드를 제공해야 해요. 컴파일러는 이를 이용해 메타객체가 지원하는 원형이 무엇인지 판단해요. 이 섹션의 나머지 부분에서는 Rakudo에 존재하는 각 원형이 어떻게 동작하는지 다룰게요.
parametric
매개변수형(parametric) 타입은 임의 개수의 타입 매개변수를 가질 수 있는 불완전한 타입이에요. 여기서 타입 매개변수는 타입 자체의 매개변수를 가리키며, 시그니처가 포함하도록 허용하는 어떤 객체든 될 수 있어요 (타입만이 아니라). 타입 인자로 매개변수화되면 매개변수형 타입은 어떤 종류의 더 완전한 타입을 만들어내요.
HOW가 매개변수화를 지원한다면 parametric 원형을 가져야 하고 parameterize 메타메서드를 제공해야 해요. parameterize 메타메서드는 메타객체를 받아야 하고, 인자로 임의 개수의 타입 매개변수를 받아 메타객체를 반환해야 해요. 예를 들어 타입을 임의의 타입 인자로 매개변수화할 수 있게 하는 parameterize 메타메서드는 이런 시그니처를 가질 수 있어요:
method parameterize(Mu $obj is raw, |parameters --> Mu)
매개변수형 클래스와 그라마 (Parametric classes and grammars)
매개변수형 원형이 구현되는 방식 때문에, 타입이 parametric 원형을 가지지 않아도 parameterize 메타메서드를 부여해 클래스와 그라마에 매개변수화 지원을 덧붙이는 것이 가능해요. 이는 롤의 기능 때문에 롤이 매개변수형 타입에 부적합한 경우에 유용할 수 있어요.
매개변수형 클래스·그라마가 유용한 시나리오 중 하나는, 타입의 매개변수화가 원래 매개변수형 타입의 기존 메서드나 정규식에 다중 디스패치 후보를 오버라이드하거나 추가해야 하는 경우예요. Array나 Hash 같은 타입의 매개변수화가 그런 경우인데, 선택적으로 매개변수화해서 인스턴스 값에 직접 동작하는 더 타입 안전한 메서드 버전을 혼합할 수 있어요. Rakudo에서는 이를 Metamodel::Mixins와 Metamodel::Naming이 제공하는 메타메서드를 사용해 주어진 메타객체의 믹스인을 만들고, 반환하기 전에 이름을 재설정하는 매개변수형 클래스로 구현해요. 이 기법은 multi 토큰과 조합하면 확장 가능한 그라마를 작성할 때 쓰일 수 있어요. 예를 들어:
grammar Bot::Grammar {
token TOP { <topic> || .+ }
proto token topic {*}
multi token topic:sym<command> { <command> <.ws> <command-args> }
token command { '$' <!ws>+ }
token command-args { <!ws>+ % <.ws> }
method ^parameterize(::?CLASS:U $this is raw, +roles) {
my Str:D $name = self.name: $this;
my Mu $mixin := $this.^mixin: |roles;
$mixin.^set_name: [~] $name, '[', roles.map(*.^name).join(','), ']';
$mixin
}
}
role Greetings[Str:D $name] {
multi token topic:sym<greeting> { ^ [ 'hi' | 'hello' | 'hey' | 'sup' ] <.ws> $name }
}
my constant GreetBot = Bot::Grammar[Greetings['GreetBot']];
GreetBot.parse: 'sup GreetBot';
say ~$/; # OUTPUT: «sup GreetBot»
매개변수형 클래스는 다른 종류에 매개변수화 지원을 시뮬레이션하는 데도 쓸 수 있어요. 예를 들어 Failable 생태계 모듈은 매개변수화 시 서브셋(subset)을 만들어내는 매개변수형 클래스예요. 그 모듈 자체는 필요한 것보다 더 많은 타입 객체가 만들어지지 않도록 약간의 캐싱을 하지만, 그보다 기본적인 버전은 다음과 같이 구현할 수 있어요:
class Failable {
method ^parameterize(Failable:U $this is raw, Mu $obj is raw --> Mu) {
Metamodel::SubsetHOW.new_type:
name => $this.^name ~ '[' ~ $obj.^name ~ ']',
refinee => Metamodel::Primitives.is_type($obj, Any) ?? Any !! Mu,
refinement => $obj | Failure
}
}