Dart 확장 메서드(extension method)의 기초

Dart 확장 메서드(extension method)의 기초

앞으로 Dart 언어에 추가될 확장 메서드 기능이 어떤 설계 고민을 거쳐 만들어졌는지 소개하는 글입니다. 이 기능을 통해 기존 타입에 새 멤버를 (마치) 추가할 수 있게 되는데요, 앞으로 나올 Dart SDK 버전에서 만나게 될 예정입니다.

출처: Dart extension method fundamentals

본문

곧 나올 버전에서 Dart 언어에 확장 메서드라는 새 기능이 추가돼요. 이 기능을 쓰면 기존 타입에 새 멤버를 (있는 척하며) 추가할 수 있어요. 확장 메서드는 실제로는 그냥 정적 함수인데도, 일반 메서드처럼 호출할 수 있죠.

o.extensionMethod(42)

왜 확장 메서드를 추가하는 걸까요? 어디에 좋은 걸까요? 어떻게 쓰는 걸까요? 그리고 다른 멤버도 추가할 수 있는데 왜 '확장 메서드(extension method)'라고 부를까요? 마지막 질문은 답이 쉬워요. 개인적으로는 확장 멤버로 생각하지만, '확장 메서드'가 작업 중 붙인 이름이었고 다른 언어에서도 비슷한 기능을 그렇게 부르거든요. 그래서 익숙하고 놀라움 없는 이름을 고른 거예요. 여기서 확장 getter, setter, 연산자는 다루지 않지만, 원한다면 String% 연산자를 추가하는 것도 얼마든지 가능해요. 이름이 뭐든 상관없이요.

이 기능을 설계한 사람 중 하나로서, 누군가 먼저 질문하기 전에 제가 기회를 잡아 이 질문들에 모두 답해볼게요. 기능을 마치기 전에 이 글을 공개한 덕분에, 더 이상 사실이 아닌 내용들을 미리 다듬어 뺄 수도 있었어요!

그런데 먼저 잠깐 샛길로 빠져볼게요.

확장 메서드 이전에는 어떻게 했을까

순전히 가상의 상황을 하나 가정해볼게요. 저는 FuturecatchError 함수가 마음에 안 들어서, 더 새롭고 반짝이는 더 좋은 무언가로 바꾸고 싶다고 생각해봐요. 가령 이 함수가 제대로 된 함수 타입 대신 Function을 인자로 받는데, 이건 충분히 합리적인 역사적 이유 때문이고, 그 결과 정적 타입 검사가 전혀 되지 않는다고 해볼게요. 그건 나쁜 일이고, 저는 그 메서드가 부끄러워해야 한다고 생각해요.

당연히 이 함수를 지울 수는 없어요. 그러면 지금까지 만들어진 진지한 Dart 프로그램이 전부 망가지니까요.

그렇다면 최소한 Future<T>에 새 메서드를 하나 추가해서, 사용자들이 그걸 쓸 수 있게 하고 싶을 거예요. 가령 이렇게요:

abstract class Future<T> {
  ...
  /// Catches any [error] of type [E].
  Future<T> onError<E>(FutureOr<T> handleError(E error, StackTrace stack)) =>
      this.catchError(...something clever...);
}

이건 이렇게 호출할 수 있죠:

Future<int> eventualInteger = ...;
eventualInteger.onError((FormatException e, s) => ...).then(...);

안타깝게도 이걸 Future 클래스에 그냥 추가할 수는 없어요. 그렇게 하면 Future 인터페이스에도 추가되기 때문에, 그 인터페이스를 구현하는 다른 모든 클래스가 불완전해져서 더 이상 컴파일되지 않게 되거든요. 한때 Future를 구현하는 클래스가 76개나 된다고 센 적이 있었는데, 그건 꽤 오래 전 일이고 지금은 세는 것조차 그만뒀어요. 여전히 모두를 망가뜨릴 수는 없으니, 이 방법도 선택지에서 제외돼요.

그럼 정적 헬퍼 함수를 쓸게요:

Future<T> onFutureError<T, E>(Future<T> source,
    FutureOr<T> handleError(E error, StackTrace stack)) =>
    source.catchError(...something clever...);

이건 이렇게 호출하겠죠:

Future<int> eventualInteger = ...;
onFutureError(eventualInteger,
    (FormatException e, s) => ...).then(...);

거의 똑같이 안타깝게도, 이건 읽기에 영 좋지 않아요. 우리는 . 기반의 메서드 체이닝을 좋아하는데, 그 이유는 왼쪽에서 오른쪽으로 읽을 수 있기 때문이에요. "이걸 하고, 그다음 저걸 하고, 그다음 뭔가 더 하기". 반면 정적 헬퍼 함수를 쓰면 이렇게 읽게 되죠. "다음에 나오는 것에 저걸 해라: 이걸 하고, 그 후에 뭔가 더 하기" … 뭐라고요? 같은 흐름, 같은 율동감이 전혀 없어요. 실전에서는 거의 읽을 수가 없다시피 해요.

좋아요, 저는 목표를 굽히지 않을 거예요. 그래서 Future 클래스를 개선하는 대신, 새롭고 개선된 인터페이스를 도입하고 사용자가 기존 인터페이스를 감쌀 방법을 제공할게요:

class MyFuture<T> {
  Future<T> _wrappee;
  MyFuture(Future<T> future) : _wrappee = future;
  Future<T> onError<E>(
      FutureOr<T> handleError(E error, StackTrace stack)) =>
      _wrappee.catchError(...something clever...);
}

이건 이렇게 쓸 수 있죠:

Future<int> eventualInteger = ...;
MyFuture(eventualInteger).onError(
    (FormatException e, s) => ...).then(...);

아마 MyFutureFuture를 구현하고 모든 Future 멤버를 _wrappee 쪽으로 전달하면서, 모든 메서드가 다시 MyFuture 래퍼를 돌려줘서 계속 이어 나갈 수 있게도 만들 거예요.

class MyFuture<T> {
  Future<T> _wrappee;
  MyFuture(Future<T> future) : _wrappee = future;
  MyFuture<R> then<R>(...) => MyFuture(_wrappee.then<R>(...));
  /// Forward other `Future` methods too.
  MyFuture<T> onError<E>(
      FutureOr<T> handleError(E error, StackTrace stack)) =>
      MyFuture(_wrappee.catchError(...something clever...));
}

제 생각에는 꽤 매끄럽죠!

확장 메서드가 생기기 전에는 이게 거의 최선이었어요. 그런데 이 방법은 래퍼를 일일이 직접 추가해야 하고, 추가된 래퍼 객체와 중간 전달 함수들 때문에 성능 손실까지 감수해야 하죠.

확장 메서드로는 이렇게 할 수 있어요

어두운 '확장 없는 시대'를 벗어나면, 저는 확장 메서드로 정말 원하던 걸 얻을 수 있어요. 이렇게 쓰면 되죠:

extension MyFuture<T> on Future<T> {
  Future<T> onError<E>(
      FutureOr<T> handleError(E error, StackTrace stack)) =>
      this.catchError(...something clever...);
}

그리고 이렇게 호출할 수 있어요:

Future<int> eventualInteger = ...;
eventualInteger.onError((FormatException e, s) => ...).then(...);

이게 전부예요. 다섯 줄로 미션이 완료됐죠!

"그런데 이게 어떻게 동작하지?"라고 물을 수 있겠네요. 아주 잘 동작해요, 감사합니다.

사실 이건 래퍼 클래스와 거의 똑같이 행동해요. 실체는 그냥 정적 헬퍼 함수인데도 말이죠. MyFuture(eventualInteger).onError(...)이라고 명시적으로 쓰는 것도 가능해요. 마치 확장이 래퍼 클래스인 것처럼요. 실제로는 아니지만, 거의 그런 것처럼 보이고 행동해요. 그리고 명시적인 감싸기를 생략하고, 타입이 맞을 때 암시적으로 적용되게 할 수도 있어요.

(아닌) 래퍼 클래스

extension 선언의 설계는 classmixin 선언처럼 보이도록 의도적으로 만들어졌어요. 숨겨진 _wrappee를 가진 래퍼 클래스인 것처럼 행동하죠. 선언 안에 정적 멤버를 둘 수도 있고, classmixin 선언의 정적 멤버와 똑같이 동작해요.

래퍼 클래스에 비해 한 가지 개선점이 있어요. 인스턴스 멤버 안에서 this를 써서 래퍼 객체 대신 _wrappee를 가리킬 수 있다는 거죠.

this의 의미를 바꾼 것은 단순한 개선 때문이 아니에요. 이것들은 정적 확장 메서드이고, 아까 말했듯 정적 함수를 호출하는 더 편리한 방법에 불과해요. 즉 래퍼 객체는 존재하지 않아요. 그런 객체는 애초에 없었고, 우리가 그랬던 척만 한 거예요. 그러니 존재하지 않는 객체를 this가 가리키게 할 수는 없죠.

또한 MyFuture(eventualInteger)을 값으로 쓰는 것도 허용되지 않아요. 그래서 var myFuture = MyFuture(eventualInteger)처럼 쓰려고 하면 허용되지 않아요. MyFuture(eventualInteger)을 쓸 수 있는 유일한 방법은 확장 멤버 호출의 대상으로 쓰는 것뿐이에요.

MyFuture(eventualInteger).onError(...); // GOOD: Use to call method.
var x = MyFuture(eventualInteger); // BAD: Use as stand-alone value.

이건 super로 메서드를 호출할 수는 있지만 그 값을 가져다 쓸 수는 없는 것과 같아요. 라이브러리 접두사(prefix)와도 같고요. 할 수 있는 건 멤버에 접근하는 것뿐이에요. 값을 취급할 수는 없는데, 그 이유는 값이 없고 가질 수도 없기 때문이에요.

객체가 없기 때문에 extension 선언에는 인스턴스 필드를 선언할 수 없어요. getter와 setter는 선언할 수 있고, 어쩌면 Expando로 뒷받침할 수도 있어요. 또 확장은 어떤 생성자도 선언할 수 없어요. 생성되는 것이 아무것도 없으니까요. 감싸는 대상 객체를 받는 생성자가 있는 척만 할 뿐이에요.

타입을 (아니) 확장해요

확장 멤버를 쓸 때마다 MyFuture(...)로 감싸야만 한다면, 그건 별로 개선이 아니겠죠. 그냥 래퍼 클래스를 직접 쓰고, 컴파일러 엔지니어들이 중간 객체를 최적화해 없애는 데 시간을 쓰는 편이 나을 거예요.

위에서 eventualInteger.onError(...)이라고 쓸 수 있다고 말했어요. 이게 동작하는 이유는, 표현식을 정적 타입과 호출하는 멤버의 이름에 기반해 암시적으로 감싸기 때문이에요. 다음 조건이 모두 맞을 때 expr.method()을 자동으로 Ext(expr).method()처럼 감싸요:

  • expr의 정적 타입에 (기본) 이름이 method인 멤버가 없다. (인터페이스가 항상 이긴다.)
  • 확장 Ext가 현재 라이브러리 스코프에 import되거나 선언되어 있다. (확장에 접근 가능하다.)
  • 확장이 기본 이름이 method인 멤버를 선언하고, expr의 정적 타입이 Ext 선언의 on 타입의 하위 타입이다. (확장이 적용 가능하다.)

멤버 호출에 접근 가능하고 적용 가능한 확장이 둘 이상이라면, 어느 쪽이 충돌에서 이길지에 대한 규칙이 있어요. 어떤 경우에는 승자를 정할 방법이 없어서, 그냥 컴파일 타임 에러가 나요. 이 규칙은 오직 확장 선언의 on 타입에만 의존하고, 멤버 선언에는 의존하지 않아요. (Dart에는 '오버로딩'이 없어요. 같은 이름에 서로 다른 시그니처를 가진 메서드를 여러 개 두고 인자 구조나 타입에 따라 고르는 방식이죠. 확장 메서드도 오버로딩을 얻을 수 있는 뒷문을 제공하지 않아요.)

전부 정적이에요

위에서 '정적 확장 메서드'라고 한 데는 이유가 있어요.

Dart는 정적으로 타입이 지정돼요. 컴파일러는 컴파일 시점에 모든 표현식의 타입을 알아요. 그래서 target.member(42)이라고 쓰고 member가 확장 멤버라면, 컴파일러는 target을 어떤 확장으로 암시적으로 감쌀지 알아내야, 전체 멤버 호출의 타입을 찾을 수 있어요.

타깃 표현식의 타입을 찾는 것과 멤버 호출의 타입을 찾는 것 사이에 암시적 확장 감싸기가 이뤄져야 한다면, '확장 추론'이 점점 더 부정확하게 불리는 '타입 추론' 단계에서 일어나야 한다는 건 당연해 보여요. 이 단계는 대부분 빠진 제네릭을 채워 넣는 것으로 유명하죠.

MyFuture 확장과 onError 메서드가 둘 다 제네릭인데도 저는 eventualInteger.onError((FormatException e, s) {...})이라고 썼어요. 타입 추론을 하는 동안 Dart 컴파일러는 확장을 선택하면서 빠진 타입 인자도 함께 추론해요. 여기서 먼저 MyFuture 확장을 쓰기로 결정하고, 암시적 래퍼를 넣은 다음, 해당하는 래퍼 클래스에 대해 하듯 확장 적용 MyFuture(eventualInteger).onError((FormatException e, s) {...})에 대해 정확히 같은 방식으로 타입 추론을 수행해요:

class MyFuture<T> {
  Future<T> _wrappee;
  MyFuture(Future<T> future) : _wrappee = future;
  MyFuture<T> onError<E>(
      FutureOr<T> handleError(E error, StackTrace stack)) =>
      _wrappee.catchError(...something clever...);
}

이 경우 타입 추론은 다음 확장 적용과 호출의 완전한 타입을 추론해요:

MyFuture<int>(eventualInteger).onError<FormatException>(
    (FormatException e, StackTrace s) {...});

즉 확장의 타입 인자는 감싸진 표현식의 정적 타입에 기반해요. Future<num> fut = Future<int>.value(42);가 있다면 fut.onError(...)MyFuture의 타입 매개변수 T를 컴파일 시점에 int가 아니라 num으로 묶어요. 다른 추론된 타입 인자들과 마찬가지로 전부 정적이에요.

그리고 이는 dynamic으로 타입이 지정된 타깃에서는 확장 멤버를 절대 호출할 수 없다는 뜻이기도 해요.

충돌 해결

위에서 말했듯, 스코프 안에 적용할 수 있는 확장이 둘 이상이면 어느 확장이 이길지에 대한 규칙이 있어요. 기본적으로는 호출하려는 멤버가 있는 표현식의 실제 타입에 on 타입이 가장 가까운 확장이 이겨요. 단 몇 가지 주의점과 동점 처리 규칙이 있죠. 함께 작성되는 확장들 사이에서는 보통 "그냥 잘 동작해요". 그 세부 사항 대신, 잘 동작하지 않을 때 어떻게 해야 할지 알려드릴게요.

같은 타입과 멤버 이름에 대해 서로 다른 두 저자가 충돌하는 확장을 작성했다면 문제가 생길 수 있어요. 가령 확장 Ext1Ext2가 둘 다 여러분의 List 객체에 적용되는 bubbleSort 메서드를 정의하고, 충돌의 승자가 명확하지 않거나, 이긴 쪽이 실제로 호출하고 싶은 쪽이 아니라고 해볼게요(예: Ext2가 이기는데 Ext1.bubbleSort를 호출하고 싶은 경우). 그러면 뭔가 해야 해요.

가장 쉬운 해결책은 명시적 확장 적용을 쓰는 거예요: Ext1(list).bubbleSort(). 이렇게 하면 자동 해석을 건너뛰고 원하는 쪽을 골라요. 충돌이 몇 개뿐이라면 이 방법이 쉽고 읽기에도 좋아요.

하지만 같은 파일에 충돌이 삼백 개 있다면, 타이핑을 덜 하게 만들고 싶을 거예요. 확장이 호출에 적용 가능한지 여부를 바꾸기는 어렵지만, 접근 가능한지 여부는 바꿀 수 있어요.

그럴 때는 import하는 곳에서 충돌하는 확장을 숨기면 돼요: import "ext2lib.dart" hide Ext2;. 이렇게 하면 Ext2 확장이 현재 라이브러리 스코프로 import되지 않아서 접근 불가능해져요. 당연히 ext2lib.dart를 아예 import하지 않는 방법도 있겠지만, 그 라이브러리에서 확장만 가져다 쓰는 게 아니라면 그건 비현실적이에요.

(12월 11일 수정) 여기서 한때는 충돌하는 확장 중 하나를 접두사와 함께 import하면 암시적으로 쓸 수 없게 된다고 말했었어요. 그런데 어떤 사람들은 확장 메서드를 그들이 확장하는 클래스와 같은 라이브러리에 선언하는데, 그 라이브러리가 접두사와 함께 import되면 동작하지 않는 게 정말 불편하다는 게 드러났어요. 그래서 그걸 고쳤어요. 접두사와 함께 import된 확장도 암시적으로 동작해요. 같은 라이브러리에서 충돌하는 확장 두 개를 정말 써야 한다면, 충돌이 있는 곳마다 명시적 확장 적용을 써야 해요. 충돌하는 확장이 반복되는 문제로 드러나면, 암시적 확장을 끌 다른 방법을 추가하는 것도 앞으로 고려할 수 있어요.

요약

Dart에는 다가오는 릴리스에서 확장 메서드가 추가될 거예요. 정적 함수를 부르는 멋진 방법이죠.

인스턴스 메서드, 연산자, setter, getter에 대해 확장 멤버를 정의할 수 있어요. 단, 필드에는 정의할 수 없어요.

확장 메서드는 명시적으로 호출하거나, 인터페이스 멤버나 다른 확장과 충돌이 없다면 암시적으로 호출할 수 있어요:

Ext1(list).bubbleSort() // Explicit, like it's a wrapper class.
list.bubbleSort() // Implicitly, like it extends the type.

암시적 호출은 명시적 호출과 동일하게 동작하지만, 먼저 어떤 확장이 적용되는지 추론해요. 충돌하는 확장 때문에 확장 추론이 실패한다면 다음 중 하나를 할 수 있어요:

  • 확장을 명시적으로 적용한다.
  • 충돌하는 확장을 아예 import하지 않는다. (import를 제거하거나 확장을 숨긴다.)
  • (12월 11일 수정) 그게 전부예요. (지금은요.)

확장은 정적이에요. 확장에 관한 모든 것이 정적 타입 기반으로 정해져요.

책임감 있게 즐기세요!

더 알아보기