Dart 2: `void`의 유산

Dart 2: void의 유산

StackOverflow, Gitter, 심지어 구글 내부 지원 채널에서도 가장 많이 보이는 질문 중 하나가 있어요. Dart 2의 내장 타입인 Object, dynamic, void, Null의 차이가 뭔지 묻는 질문이죠. 결론부터 말하면, Null(다른 언어에서는 Bottom, 즉 'Nothing'이라고도 하죠)은 실제 사용자 코드에서 대부분 쓰면 안 되는 타입이에요. 앞으로 이 사용을 조심스럽게 줄이는 방향의 글과 린트가 더 나올 거라고 예상해요.

출처: Dart 2: Legacy of the void

본문

나머지 세 타입은 상황이 덜 명확해요. Dart 2에서는 어떤 것이든 런타임에 dynamic, Object, void가 될 수 있으니까요. 단지 정적 타입 시그니처만 다를 뿐이에요. 그래서 어떤 타입 시그니처를 언제 써야 하는지, 실제 예시 몇 가지로 살펴볼게요.

Object

Object는 Dart 클래스 계층의 루트 클래스예요. Dart의 모든 클래스는 Object의 서브클래스죠. int, double, bool 같은 '원시' 타입까지도요. Object는 몇 가지를 보장해요. hashCode 프로퍼티, == 연산자, toString 메서드가 그것이에요.

실용적으로 말하면, 저는 Object를 빈자의 유니온 타입처럼 써요. 사용자가 뭔가를 사용하기 전에 is 연산자로 실제 타입을 알아내길 기대하는 방식이죠. 저는 dynamic을 쓰지 않아요. 다음 절에서 설명하겠지만, dynamic은 중요한 정적 분석을 꺼 버리고 잘못된 상태로 빠지기 훨씬 쉬워지게 하거든요.

Object readProperty(String name) { ... }

void main() {
  var age = readProperty('name');
  if (age is int) {
    print('I am $age years old');
  } else if (age is String) {
    print(age);
  }
}

또 다른 옵션은 데이터 구조의 내부 타입이 뭔지 신경 쓰지 않겠다고 선언하는 데 Object를 쓰는 거예요. 예를 들어 List<Object>는 '무엇이든 담긴 리스트'를 뜻할 수 있죠. 이건 예를 들어 List의 모든 요소의 hashCode를 합치는 함수를 쓸 때 유용해요.

int hashList(List<Object> elements) { ... }

Object가 (dynamic에 비해) 갖는 좋은 성질은, 신뢰성 있게 존재하지 않는 메서드를 호출하려고 하면 즉시 분석과 컴파일러 피드백을 받는다는 거예요. 예를 들어 아래 코드는 정적 오류를 만들어요.

void main() {
  Object a = 5;
  a.aMethodThatDoesNotExist();
}

실제로는 Object가 상당히(그리고 의도적으로) 제한적이에요. 제 바람은 Dart가 메서드 오버로드를 지원해서, 실제 코드에서 Object 타입 사용을 극적으로 줄일 수 있게 되는 거예요.

dynamic

저는 개인적으로 Dart 2에서 dynamic 타입을 절대 쓰지 않아요. 제 관점에서 dynamic은 일종의 Object와, 도구와 컴파일러에 정적 분석 검사를 끄라고 알려주는 특별한 지시문의 결합이에요. 즉 아래 코드는 합법적이고, 런타임에만(정적으로가 아니라!) 오류를 보여줘요.

void main() {
  dynamic x = 5;
  x.aMethodThatDoesNotExist();
}

Dart 1에서는 dynamic이 도처에 있었고, 그 외의 어떤 정적 타입도 IDE와 정적 분석 지원을 위한 것이었어요. 컴파일러(와 런타임)는 모든 것을 dynamic으로 취급했죠. Dart 2에도 우연히 dynamic 타입 변수를 만들어 버리는 불행한 'gotcha'가 몇 가지 남아 있어요.

computeAge() => 5; // Return type is dynamic

void main() {
  var name; // Static type is dynamic
  var animals = []; // Static and runtime type is List<dynamic>
}

더 나쁜 건, dynamic 호출이 Dart 2에서 아주 중요한 타입 정보를 잃어버리는 거예요.

class User {
  String name;
}

void main() {
  var users = []; // Implicitly List<dynamic>, remember?
  users.add(new User()..name = 'Matan');

  // Runtime error: List<dynamic> is not a Iterable<String>
  Iterable<String> names = users.map((u) => u.name);
}

이 오류의 이유는 실제 호출이 이렇게 되기 때문이에요.

users.map((dynamic u) => u.name);

이 호출은 Iterable<String>을 만들어 내기에 충분한 정적 타입 정보를 갖고 있지 않아요. users의 타입을 올바르게 고치고(그리고 dynamic 호출을 피하고) 나면 모든 게 동작해요.

void main() {
  // We also could have written `var users = <User>[
  var users = [new User()..name = 'Matan'];

  // OK!
  Iterable<String> names = users.map((u) => u.name);
}

void

마지막으로, Dart 2에서 가장 새로운 타입인 void예요. Dart 1에서 void는 함수의 반환 타입으로만 쓸 수 있었어요(예: void main()). 그런데 Dart 2에서는 일반화되어 다른 곳에서도 쓸 수 있게 됐어요. 예를 들어 Future<void>처럼요.

void 타입은 의미상 Object와 비슷해요(무엇이든 될 수 있죠). 다만 추가 제약이 있어요. void 타입은 아무것에도 쓸 수 없고(==이나 hashCode조차도), void 타입에 무언가를 할당하는 것도 유효하지 않아요.

void foo() {}

void main() {
  var bar = foo(); // Invalid
}

실용적으로 저는 void를 '무엇이든이되 요소를 신경 쓰지 않음'이라는 뜻으로 쓰거나, 더 흔하게는 '생략됨'이라는 뜻으로 써요. Future<void>Stream<void>처럼 말이죠.

/// Clear the cache.
Future<void> purgeCache() { ... }

위 코드 조각에서 저는 사용자가 제공된 Future의 반환 값을 쓰려고 시도하는 걸 원하지 않아요. 그 값은 관계없으니까요. 이 목적으로 Future<Null>을 쓰는 예시를 본 적이 있는데, 그건 Future<void>가 가능해지기 전의 우회책이었어요.

예를 들어 아래 코드는 정적으로는 OK지만, Dart 2에서 런타임에는 유효하지 않아요.

import 'dart:async';

Future<String> _doAThing() async => 'Test';
Future<Null> doAThing() async => _doAThing();

void main() async {
  // Future<String> is not a subtype of type FutureOr<Null>
  await doAThing();
}

반면 doAThing()Future<void>를 쓰는 것은 유효하고 올바른 방식이에요.

또 다른 예시는 이벤트 데이터 없이 발생하는 Stream일 거예요.

/// Fires an event when a user signs-out of the system.
Stream<void> get onLogOut { ... }

더 실용적인 사용법은, 쓰지 않을 제네릭 타입 인자를 가진 클래스를 구현할 때예요. 예를 들어 널리 쓰이는 Visitor 패턴을 구현할 때, C(컨텍스트) 타입 인자가 쓰이지 않으면 void를 넘겨서 그걸 무시할 수 있어요.

abstract class Visitor<N, C> {
  N visitNode(N node, [C context]);
}

class IdentityVisitor<N> extends Visitor<N, void> {
  @override
  N visitNode(N node, [_]) => node;
}

이 짧은 글이 Object, dynamic, void를 쓸 때의 API 결정에 도움이 됐길 바라요. 다른 질문이나 아이디어가 있다면 댓글 남겨 주세요!

더 알아보기