확장 타입
확장 타입 (Extension types)
기존 타입의 인터페이스를 바꾸고 싶은데 실제 래퍼 객체를 만들어 비용을 치르고 싶지 않을 때가 있어요. 확장 타입은 기존 타입을 "감싸서" 정적 전용의 다른 인터페이스를 제공하는 컴파일 타임 추상화예요. 실제 래퍼의 비용 없이 기존 타입의 인터페이스를 쉽게 수정할 수 있어서, 정적 JS 상호운용성(interop)의 주요 구성 요소이기도 하죠.
출처: Dart 공식 문서
본문
확장 타입은 기본 타입(표현 타입, representation type)의 객체에 사용할 수 있는 연산(또는 인터페이스) 집합에 규율을 강제해요. 확장 타입의 인터페이스를 정의할 때 표현 타입의 멤버 중 일부를 재사용하고, 일부는 생략하고, 일부는 교체하고, 새 기능을 추가하도록 선택할 수 있어요.
다음 예시는 int 타입을 감싸서 ID 번호에 합당한 연산만 허용하는 확장 타입을 만들어요.
extension type IdNumber(int id) {
// Wraps the 'int' type's '<' operator:
operator <(IdNumber other) => id < other.id;
// Doesn't declare the '+' operator, for example,
// because addition does not make sense for ID numbers.
}
void main() {
// Without the discipline of an extension type,
// 'int' exposes ID numbers to unsafe operations:
int myUnsafeId = 42424242;
myUnsafeId = myUnsafeId + 10; // This works, but shouldn't be allowed for IDs.
var safeId = IdNumber(42424242);
safeId + 10; // Compile-time error: No '+' operator.
myUnsafeId = safeId; // Compile-time error: Wrong type.
myUnsafeId = safeId as int; // OK: Run-time cast to representation type.
safeId < IdNumber(42424241); // OK: Uses wrapped '<' operator.
}
확장 타입은 래퍼 클래스(wrapper class) 와 같은 목적을 하지만, 추가 런타임 객체를 만들 필요가 없어요. 많은 객체를 감싸야 할 때 그 비용은 커질 수 있죠. 확장 타입은 정적 전용이고 런타임에 컴파일되어 사라지므로 사실상 비용이 0이에요.
확장 메서드(확장, extension이라고도 함)는 확장 타입과 비슷한 정적 추상화예요. 다만 확장 메서드는 기본 타입의 모든 인스턴스에 직접 기능을 추가해요. 확장 타입은 달라요. 확장 타입의 인터페이스는 정적 타입이 그 확장 타입인 표현식에만 적용되죠. 기본적으로 기본 타입의 인터페이스와 구별돼요.
문법 (Syntax)
선언 (Declaration)
extension type 선언과 이름, 그 뒤에 괄호 안의 표현 타입 선언 으로 새 확장 타입을 정의해요.
extension type E(int i) {
// Define set of operations.
}
표현 타입 선언 (int i)은 확장 타입 E의 기본 타입이 int이고, 표현 객체 에 대한 참조 이름이 i라는 것을 지정해요. 이 선언은 또한 다음을 도입해요.
- 표현 객체에 대한 암묵적 getter. 반환 타입은 표현 타입:
int get i. - 암묵적 생성자:
E(int i) : i = i.
표현 getter는 기본 타입으로 타입된 표현 객체에 접근을 제공해요. getter는 확장 타입 본문 안에서 유효 범위에 있고, 다른 getter처럼 이름으로 접근할 수 있어요.
- 확장 타입 본문 안에서
i(또는this.i)로. - 바깥에서는
e.i로 프로퍼티 추출해서(e의 정적 타입이 확장 타입일 때).
확장 타입 선언은 클래스나 확장처럼 타입 매개변수도 포함할 수 있어요.
extension type E<T>(List<T> elements) {
// ...
}
생성자 (Constructors)
확장 타입 본문에서 생성자를 선택적으로 선언할 수 있어요. 표현 선언 자체가 암묵적 생성자이므로, 기본적으로 확장 타입의 이름 없는 생성자 자리를 차지해요. 추가적인, 리디렉션하지 않는 생성 생성자(generative constructor)는 초기화 리스트나 형식 매개변수에서 this.i를 사용해 표현 객체의 인스턴스 변수를 초기화해야 해요.
extension type E(int i) {
E.n(this.i);
E.m(int j, String foo) : i = j + foo.length;
}
void main() {
E(4); // Implicit unnamed constructor.
E.n(3); // Named constructor.
E.m(5, "Hello!"); // Named constructor with additional parameters.
}
또는 표현 선언 생성자에 이름을 붙일 수 있어요. 그 경우 본문에 이름 없는 생성자를 위한 자리가 생겨요.
extension type const E._(int it) {
E(): this._(42);
E.otherName(this.it);
}
void main2() {
E();
const E._(2);
E.otherName(3);
}
새 생성자를 정의하는 대신 생성자를 완전히 숨길 수도 있어요. 클래스의 비공개 생성자 문법 _와 같아요. 예를 들어 기본 타입이 int인데도 클라이언트가 String으로만 E를 만들게 하고 싶다면:
extension type E._(int i) {
E.fromString(String foo) : i = int.parse(foo);
}
전달 생성자(forwarding generative constructor)나 팩토리 생성자도 선언할 수 있어요. 팩토리 생성자는 하위 확장 타입의 생성자로 전달할 수도 있죠.
멤버 (Members)
확장 타입 본문에서 멤버를 선언해서, 클래스 멤버를 정의하는 것과 같은 방식으로 그 인터페이스를 정의해요. 확장 타입 멤버는 메서드, getter, setter, 연산자가 될 수 있어요. (external이 아닌 인스턴스 변수와 추상 멤버는 허용되지 않아요.)
extension type NumberE(int value) {
// Operator:
NumberE operator +(NumberE other) =>
NumberE(value + other.value);
// Getter:
NumberE get myNum => this;
// Method:
bool isValid() => !value.isNegative;
}
표현 타입의 인터페이스 멤버는 기본적으로 확장 타입의 인터페이스 멤버가 아니에요. 표현 타입의 단일 멤버를 확장 타입에서 사용 가능하게 만들려면 NumberE의 operator +처럼 확장 타입 정의에 그 멤버의 선언을 써야 해요. 표현 타입과 무관한 새 멤버(i getter, isValid 메서드 같은)를 정의할 수도 있어요.
implements
선택적으로 implements 절을 써서:
- 확장 타입에 하위 타입 관계를 도입하고,
- 표현 객체의 멤버를 확장 타입 인터페이스에 추가할 수 있어요.
implements 절은 확장 메서드와 그 on 타입 사이와 같은 적용 관계(applicability relationship)를 도입해요. 수퍼타입에 적용 가능한 멤버는, 하위 타입에 같은 이름의 선언이 없다면 하위 타입에도 적용 가능해요.
확장 타입은 다음만 구현할 수 있어요.
그 표현 타입. 이 경우 표현 타입의 모든 멤버가 확장 타입에 암묵적으로 사용 가능해져요.
extension type NumberI(int i)
implements int{
// 'NumberI' can invoke all members of 'int',
// plus anything else it declares here.
}
표현 타입의 수퍼타입. 이 경우 수퍼타입의 멤버는 (반드시 표현 타입의 모든 멤버는 아니더라도) 사용 가능해져요.
extension type Sequence<T>(List<T> _) implements Iterable<T> {
// Better operations than List.
}
extension type Id(int _id) implements Object {
// Makes the extension type non-nullable.
static Id? tryParse(String source) => int.tryParse(source) as Id?;
}
같은 표현 타입에서 유효한 또 다른 확장 타입. 이 경우 여러 확장 타입에 걸쳐 연산을 재사용할 수 있어요(다중 상속과 비슷하게).
extension type const Opt<T>._(({T value})? _) {
const factory Opt(T value) = Val<T>;
const factory Opt.none() = Non<T>;
}
extension type const Val<T>._(({T value}) _) implements Opt<T> {
const Val(T value) : this._((value: value));
T get value => _.value;
}
extension type const Non<T>._(Null _) implements Opt<Never> {
const Non() : this._(null);
}
Usage 섹션에서 다양한 시나리오에서 implements의 효과에 대해 더 자세히 배울 수 있어요.
@redeclare
수퍼타입의 멤버와 이름을 공유하는 확장 타입 멤버를 선언하는 것은 클래스 사이의 오버라이드 관계가 아니라, 재선언(redeclaration)이에요. 확장 타입 멤버 선언은 같은 이름의 수퍼타입 멤버를 완전히 대체해요. 같은 함수에 대해 대체 구현을 제공하는 건 불가능하죠.
package:meta의 @redeclare 어노테이션을 써서, 컴파일러에게 수퍼타입 멤버와 같은 이름을 쓰는 것을 알고 선택했음을 알릴 수 있어요. 그러면 분석기가 실제로 그렇지 않을 때(예를 들어 이름 중 하나를 잘못 입력한 경우) 경고해줘요.
import 'package:meta/meta.dart';
extension type MyString(String _) implements String {
// Replaces 'String.operator[]'.
@redeclare
int operator [](int index) => codeUnitAt(index);
}
annotate_redeclares 린트(lint)를 켜면, 수퍼인터페이스 멤버를 숨기는데 @redeclare로 어노테이션하지 않은 확장 타입 메서드를 선언하면 경고를 받을 수도 있어요.
사용법 (Usage)
확장 타입을 사용하려면 클래스처럼 생성자를 호출해서 인스턴스를 만들어요.
extension type NumberE(int value) {
NumberE operator +(NumberE other) =>
NumberE(value + other.value);
NumberE get next => NumberE(value + 1);
bool isValid() => !value.isNegative;
}
void testE() {
var num = NumberE(1);
}
그런 다음 클래스 객체처럼 객체에서 멤버를 호출할 수 있어요.
확장 타입에는 똑같이 유효하지만 상당히 다른 두 가지 핵심 사용 사례가 있어요.
- 기존 타입에 확장된 인터페이스를 제공하기.
- 기존 타입에 다른 인터페이스를 제공하기.
어떤 경우든 확장 타입의 표현 타입은 결코 그 하위 타입이 아니므로, 확장 타입이 필요한 곳에 표현 타입을 대신 쓸 수 없어요.
1. 기존 타입에 확장된 인터페이스 제공하기 (Provide an extended interface to an existing type)
확장 타입이 그 표현 타입을 구현하면 "투명하다(transparent)"고 볼 수 있어요. 확장 타입이 기본 타입을 "볼" 수 있게 해주기 때문이에요.
투명한 확장 타입은 (재선언되지 않은) 표현 타입의 모든 멤버와, 자신이 정의한 보조 멤버를 호출할 수 있어요. 이렇게 해서 기존 타입에 새롭고 확장된 인터페이스를 만드는 거죠. 새 인터페이스는 정적 타입이 확장 타입인 표현식에 사용 가능해요.
즉, (투명하지 않은 확장 타입과 달리) 표현 타입의 멤버를 호출할 수 있어요.
extension type NumberT(int value)
implements int {
// Doesn't explicitly declare any members of 'int'.
NumberT get i => this;
}
void main () {
// All OK: Transparency allows invoking `int` members on the extension type:
var v1 = NumberT(1); // v1 type: NumberT
int v2 = NumberT(2); // v2 type: int
var v3 = v1.i - v1; // v3 type: int
var v4 = v2 + v1; // v4 type: int
var v5 = 2 + v1; // v5 type: int
// Error: Extension type interface is not available to representation type
v2.i;
}
새 멤버를 추가하고, 수퍼타입의 특정 멤버 이름을 재선언해서 다른 것들을 적응시키는 "대부분 투명한(mostly-transparent)" 확장 타입을 가질 수도 있어요. 이렇게 하면 메서드의 일부 매개변수에 더 엄격한 타입을 쓰거나, 예를 들어 다른 기본값을 쓸 수 있어요.
대부분 투명한 확장 타입의 또 다른 접근은 표현 타입의 수퍼타입인 타입을 구현하는 거예요. 예를 들어 표현 타입이 비공개인데, 그 수퍼타입이 클라이언트에게 중요한 인터페이스 부분을 정의할 때죠.
2. 기존 타입에 다른 인터페이스 제공하기 (Provide a different interface to an existing type)
투명하지 않은 (즉, 그 표현 타입을 implement하지 않는) 확장 타입은 정적으로 완전히 새로운 타입으로 취급돼요. 표현 타입과 구별되죠. 표현 타입에 할당할 수 없고, 표현 타입의 멤버를 노출하지 않아요.
예를 들어 Usage 아래에서 선언한 NumberE 확장 타입을 보죠.
void testE() {
var num1 = NumberE(1);
int num2 = NumberE(2); // Error: Can't assign 'NumberE' to 'int'.
num1.isValid(); // OK: Extension member invocation.
num1.isNegative(); // Error: 'NumberE' does not define 'int' member 'isNegative'.
var sum1 = num1 + num1; // OK: 'NumberE' defines '+'.
var diff1 = num1 - num1; // Error: 'NumberE' does not define 'int' member '-'.
var diff2 = num1.value - 2; // OK: Can access representation object with reference.
var sum2 = num1 + 2; // Error: Can't assign 'int' to parameter type 'NumberE'.
List<NumberE> numbers = [
NumberE(1),
num1.next, // OK: 'next' getter returns type 'NumberE'.
1, // Error: Can't assign 'int' element to list type 'NumberE'.
];
}
확장 타입을 이렇게 써서 기존 타입의 인터페이스를 대체할 수 있어요. 이렇게 하면 새 타입의 제약에 맞는 인터페이스를 모델링하면서도(도입부의 IdNumber 예시처럼), int 같은 단순한 사전 정의 타입의 성능과 편리함을 누릴 수 있어요.
이 사용 사례는 래퍼 클래스의 완전한 캡슐화에 가장 가깝게 갈 수 있는 방법이에요. 다만 현실적으로는 어느 정도 보호되는 추상화일 뿐이죠.
타입 고려사항 (Type considerations)
확장 타입은 컴파일 타임에 감싸는 구성이에요. 런타임에는 확장 타입의 흔적이 전혀 없어요. 어떤 타입 질의나 유사한 런타임 연산도 표현 타입에 대해 동작해요.
이 때문에 확장 타입은 안전하지 않은 추상화예요. 런타임에 항상 표현 타입을 알아낼 수 있고 기본 객체에 접근할 수 있으니까요.
동적 타입 테스트(e is T), 캐스트(e as T), 그리고 다른 런타임 타입 질의(switch (e) ...나 if (e case ...) 같은)는 모두 기본 표현 객체에 대해 평가되고, 그 객체의 런타임 타입에 대해 타입 검사해요. e의 정적 타입이 확장 타입일 때도, 그리고 확장 타입에 대해 테스트할 때도(case MyExtensionType(): ... ) 마찬가지예요.
void main() {
var n = NumberE(1);
// The run-time type of 'n' is the representation type 'int'.
if (n is int) print(n); // Prints 1.
// Can use 'int' methods on 'n' at run time.
if (n case int x) print(x.toRadixString(10)); // Prints 1.
switch (n) {
case int(:var isEven): print("$n (${isEven ? "even" : "odd"})"); // Prints 1 (odd).
}
}
마찬가지로 이 예시에서 매칭된 값의 정적 타입은 확장 타입이에요.
void main() {
int i = 2;
if (i is NumberE) print("It is"); // Prints 'It is'.
if (i case NumberE v) print("value: ${v.value}"); // Prints 'value: 2'.
switch (i) {
case NumberE(:var value): print("value: $value"); // Prints 'value: 2'.
}
}
i가 캐스트나 패턴 매칭에 기반해 정적 타입 NumberE를 얻으면, i는 여전히 같은 객체를 가리키고, v는 i와 같은 객체를 가리키며, 객체 자체는 그대로예요. 정적 타입만 바뀌는 거죠(그래서 이 객체에서 호출할 수 있는 메서드도 바뀌어요). 특히 타입의 변화는 생성자 호출을 수반하지 않아요. 생성자를 실행하고 싶다면(예를 들어 어떤 검증을 수행하려면) 명시적 생성자 호출(NumberE(i) 같은)을 써야 해요.
확장 타입을 쓸 때는 이 동작을 반드시 알아 두는 게 중요해요. 확장 타입은 컴파일 타임에 존재하고 중요하지만, 컴파일 동안 *지워진다(erased)*는 점을 항상 기억하세요.
예를 들어 정적 타입이 확장 타입 E인 표현식 e를 생각해 봐요. E의 표현 타입이 R이라고 가정해요. 그러면 e 값의 런타임 타입은 R의 하위 타입이에요. 타입 자체조차 지워져요. 런타임에 List<E>는 정확히 List<R>과 같은 거죠.
다시 말해, 실제 래퍼 클래스는 감싼 객체를 캡슐화할 수 있는 반면, 확장 타입은 감싼 객체에 대한 컴파일 타임 보기에 불과해요. 실제 래퍼가 더 안전하지만, 그 대가로 확장 타입은 래퍼 객체를 피할 수 있는 선택지를 주고, 일부 시나리오에서 성능을 크게 향상시킬 수 있어요.