Dart에 Primary Constructors 도입하기

Dart에 Primary Constructors 도입하기

Dart 3.13에 primary constructors를 추가하는 데 숨은 설계 결정, 트레이드오프, 문법적 설탕(syntactic sugar)에 대한 내부 이야기를 들려 드릴게요. (AI 공개 고지: 이 글의 모든 문장은 제가 직접 썼어요. 이맺음표(em dash)까지도요.)

제가 가장 좋아하는 Dart 3.13의 기능은 바로 primary constructors예요. 거기까지 가는 데는 언어 팀이 마음에 드는 설계를 갖추기까지 많은 시간과 반복이 필요했죠. 이 기능을 오래 기다려 오신 분이 많으니, 이 큰 문법 변경을 Dart에 가져오기 위해 우리가 겪었던 몇 가지 도전을 글로 남겨 볼 만하다고 생각했어요.

출처: Bringing Primary Constructors to Dart

본문

문법적 설탕에 관하여

사용자들은 수년 동안 primary constructors 같은 것을 요청해 왔어요. 아주 높은 선호를 받는 기능이죠. 그런데 곰곰이 생각해 보면 좀 이상한 일이에요. primary constructors는 Dart에서 이미 할 수 있는 일을 새로 가능하게 해 주지 않으니까요. 이미 표현할 수 있는 것에 대한, 다르고(희망적으로는 더 나은!) 문법일 뿐이에요.

1960년대에 Peter Landin은 보다 근본적이지만 불편한 언어 위에 문맥상의 장치를 쌓는 일을 가리키는 말로 "syntactic sugaring"이라는 용어를 만들었어요. 오늘날 우리는 그 용어를 명사처럼 써서 이런 기능을 "syntactic sugar"라고 부르곤 하죠.

모든 프로그래밍 언어의 영원한 적은 복잡성(complexity)이에요. 아주 사소한 기능이라도 설계하고, 명세하고, 구현하고, 테스트하고, 문서화해야 해요. 비용이 크죠. 저는 언어의 복잡성을 비행기의 무게처럼 생각해요. 어떤 정도는 작동하기 위해 필요한데, 불필요하게 무게를 더하면 비행기가 이륙하지 못할 위험이 있어요.

그런 관점에서 보면 syntactic sugar는 나쁜 생각처럼 보여요. 추가 유틸리티가 없는 추가 복잡성이니까요. 더 나쁜 것은, 일단 추가하면 그 복잡성을 사용자에게도 떠넘기게 된다는 거예요. 이제 사용자들은 무언가를 표현할 때마다 어떤 문법을 쓸지 선택해야 하죠.

이런 종류의 기능이 좋은 생각인 때는 언제일까요? (솔직히 말하면, 지난 몇 년간 저의 작업 대부분이 이런 기능을 Dart에 추가하는 일이었기 때문에, 이걸 정당화할 필요성을 느껴요.) 저는 syntactic sugar가 두 가지 방식으로 제 몫을 해낼 수 있다고 생각해요.

새 방식이 그냥 더 나을 때

우리가 다소 기계적인 태도와 EBNF에 대한 애정을 갖고 있다 해도, 언어 설계자도 사람이라 실수를 해요. 게다가 우리는 항상 배우고 있고, 우리가 섬기는 생태계는 소프트웨어를 만드는 새로운 방법을 끊임없이 발견하며, 사용자 기대도 시간이 지나면서 달라지죠.

Dart가 처음 설계되었을 때는 생성자를 호출할 때 명시적인 new 키워드를 써야 했어요. C++, Java, JavaScript 같은 언어에서 넘어오는 사용자에게 익숙하도록 의도된 것이었죠. 호출이 새 객체를 할당한다는 걸 코드에서 더 명확하게 하려는 의도였어요. 가비지 컬렉터가 좋아지고 사용자들이 자동 메모리 관리에 익숙해지면서, 대부분의 사용자는 new가 신호보다는 잡음에 가깝다고 느꼈어요.

(솔직히 말하면, Dart는 factory 생성자를 지원해서 처음부터 그 신호를 항상 훼손해 왔어요. factory 생성자는 new로 호출해도 이미 만들어진 객체를 반환할 수 있으니까요.)

Dart 2.0에서 우리는 생성자를 호출할 때 new 키워드(그리고 여러 곳에서 const)를 생략할 수 있게 하는 언어 변경을 출시했어요. 옛 문법도 여전히 지원하므로 이 변경은 본질적으로 syntactic sugar이지만, 옛 문법을 남겨 둔 건 사실상 하위 호환성을 위해서뿐이었어요.

우리는 항상 새롭고 짧은 문법을 쓰길 원해요. 불필요한 new 키워드를 자동으로 없애 주는 도구를 출시했고, 잊어버렸을 때 알려 주는 린트(lint)도 있죠. 옛 문법은 사실상 폐기되고, 시간이 지나면서 점점 덜 보여요. Dart가 처음이라면 우리가 생성자 호출에 new를 지원했다는 사실조차 몰랐을 거예요.

new가 있든 없든 생성자 호출을 지원하는 데 드는 복잡성은 낮아요. 기존 사용자가 새 문법을 배우는 데는 전환 비용이 있죠. 하지만 새 사용자는 대부분 새 방식만 배우고 옛 방식은 만나지 않아요. 두 문법 사이에서 선택할 때 인지 부하도 거의 없는데, 단순히 항상 새 것을 쓰면 되니까요(그리고 도구가 가만히 알려 주죠).

시간을 되돌려 처음부터 제대로 할 수 있는 타임머신이 없는 이상, 이게 언어의 실수를 고칠 수 있는 차선책이에요.

공통 사용 사례에 문법이 훨씬 나을 때

Dart가 공개된 지 첫 몇 년간은 enum 선언을 지원하지 않았어요. 언어가 이런 코드를 쓸 수 없게 했죠.

enum Color { red, blue, yellow }

대신 다음과 같은 것을 써야 했어요.

class Color {
  static const Color red = Color._(0, 'red');
  static const Color blue = Color._(1, 'blue');
  static const Color yellow = Color._(2, 'yellow');

  const Color._(this.index, this.name);

  final int index;
  final String name;
}

옛 Java 개발자라면 이것을 Josh Bloch의 "typesafe enum 패턴"으로 기억하실 거예요. 이 더 장황한 클래스 선언은 그 저변이 오늘날 Dart의 enum 선언과 거의 똑같이 동작해요. Dart enum 선언은 거의 전부 sugar예요. ("거의"라고 한 이유는 enum 선언이 switch에서 완전성 검사(exhaustiveness checks)를 주기 때문이에요.)

하지만 이 두 예시에서 볼 수 있듯, enum 선언은 정말 좋은 sugar예요. 단순한 enum 선언 하나가 많은 Dart 코드로 풀려 나오죠. 만약 열거 타입을 쓰는 사람이 거의 없다면, 이 사용 사례를 위해 문법을 추가할 가치가 없을 수도 있어요. 하지만 타입 안전과 데이터 검증을 우선시하는 언어에서는 enum이 아주 흔해요. Flutter 프레임워크만 해도 수십 개를 정의하죠.

비교적 작은 syntactic sugar가 때로는 많은 사용자 코드를 더 짧고 단순하게 만들어 줘요.

문법이 의도를 더 명확하게 할 때

앞 절은 간결함(brevity)이 전부인 것처럼 말했어요. 점점 더 AI 에이전트에게 토큰당 비용을 지불하면서 코드를 읽고 쓰는 세상에서는 확실히 직접적인 금전적 유인도 있어요. 하지만 단순히 글자 수의 문제는 아니에요. 다음을 고려해 보세요.

class Color {
  static const Color red = Color('red', 0xff0000);
  static const Color blue = Color('blue', 0x0000ff);
  static const Color yellow = Color('yellow', 0xff00ff);

  const Color(this.name, this.rgb);

  final String name;
  final int rgb;
}

이것은 열거 타입일까요? 그러니까, 정말 열거라는 말 그대로예요. 이 클래스를 사용하는 사람이 신경 써야 할 Color의 인스턴스가 red, blue, yellow뿐이라고 가정해도 될까요?

생성자가 public이므로 다른 라이브러리는 마음껏 생성자를 호출해 다른 색을 만들 수 있어요. 이 클래스의 의도는 색의 닫힌 목록일까요, 아니면 미리 정의된 몇 개의 값이 있는 열린 공장일까요?

코드를 읽어 봐도 알 수 없어요. 이 코드는 타입과 상수를 정의하는 많은 장치일 뿐이에요. enum을 원한다면 쓸 법한 코드처럼 보이지만, 그 장치는 의도를 드러내지 않아요. 코드는 컴파일러에게 코드가 무엇을 의미하는지 알려 주지만, 읽는 사람에게는 어떻게 사용해야 하는지 알려 주지 않아요.

이것을 enum 선언으로 바꾸면 '닫힌 값 집합'이라는 정책이 명확해져요. (그리고 이제 Dart에 진짜 enum이 있으니, 이 코드를 enum 선언으로 바꾸지 않기로 선택하는 것은 닫힌 집합이 아니라는 신호를 보내는 일일 가능성이 크죠.)

저에게는 이것이 syntactic sugar를 추가해야 하는 강력한 이유예요. 코드는 문법으로 작성되고 실행되지만, 코드를 다루는 모든 사용자가 신경 쓰는 것은 그 의미, 즉 시맨틱스예요. 코드를 올바르게 유지하려면 의도와 정책을 이해해야 하죠. AI가 우리가 성심껏 검토할 시간보다 빠르게 코드를 생성하는 세상에서는 이 점이 점점 더 중요해져요.

몇 가지 기존 언어 기능의 장치를 조합해서 컴파일러가 원하는 대로 동작하게 만들 수 있더라도, 같은 동작을 내는 syntactic sugar를 갖는 게 가치 있을 수 있어요. 더 나은 문법이 그 동작을 더 높은 추상화 수준으로 끌어올려 의도한 시맨틱스가 더 명확해지거든요. 언어 전체가 더 복잡해졌음에도 코드를 이해하는 데 필요한 인지 작업을 줄일 수 있어요.

왜 primary constructors인가요

맞아요, 저는 primary constructors에 대해 이야기해야 하는데 enum과 new 키워드를 말하고 있었네요. (단, 이제 곧 new도 이야기할 거예요!) 수년 동안 Dart 언어 저장소의 1위 공개 이슈는 data classes 기능 요청이었어요. data classes가 뭔지 모른다면, 필드 몇 개로 클래스를 정의하면 컴파일러가 동등성(equality), 해시 코드, 그 외 몇 가지를 공짜로 제공해 주는 Kotlin의 기능이에요.

그 이슈의 수백 개 댓글을 읽어 보면, 대부분의 사용자는 값 시맨틱스 부분(동등성과 해시 코드)에는 관심이 별로 없어요. 대부분 생성자를 갖고 상태를 저장하는 클래스를 더 쉽게 정의하는 방법에 대한 것이에요.

그 기능은 실제로 Kotlin의 또 다른, 더 근본적인 기능인 primary constructors에서 나온 거예요. Kotlin이 이 아이디어를 Scala에서 얻었다고 믿고 있어요. 그 이후로 C#과 Java도 이 개념을 나름대로 추가했죠.

(data classes의 값 시맨틱스 부분도 유용해요. 우리는 그것을 별도로 탐구하고 있어요.)

primary constructors에는 핵심 요소 두 가지가 있어요.

  • 클래스 헤더 안에 바로 파라미터 목록을 작성해서 생성자를 정의할 수 있어요. 그렇게 하면 생성자를 선언하는 데 키워드를 쓰거나 클래스 이름을 반복할 필요가 없어요. 또한 상태만 담고 있는 아주 단순한 클래스에서, 클래스 본문을 위한 들여쓰기와 생성자 파라미터 목록을 위한 들여쓰기, 이렇게 두 단계의 중첩과 들여쓰기도 피할 수 있죠.
  • 그 파라미터 목록 안에서, 일부 파라미터가 파라미터로부터 자동 초기화되는 해당 인스턴스 필드를 선언하도록 표시할 수 있어요.

primary constructors나 다른 어떤 syntactic sugar가 없다면 Dart에서 다음과 같은 것을 해야 해요.

class Point {
  final int x;
  final int y;

  Point(int x, int y) : x = x, y = y;
}

이 예시에서 클래스 이름을 두 번 작성해야 했어요. 상태 하나마다 타입을 두 번, 이름을 네 번 썼죠. 필드가 둘뿐이고 이름이 모두 짧아서 이 예시에서는 그리 끔찍하지 않아요. 길고 복잡한 이름과 수많은 상태를 가진 도메인 특화 작업을 다루기 시작하면 추해져요.

이것은 새로운 문제가 아니고, Dart는 오래전부터 "initializing formals"이라는 syntactic sugar를 조금 갖고 있어요.

class Point {
  final int x;
  final int y;

  Point(this.x, this.y);
}

생성자 파라미터에 this.를 쓰면 필드마다 타입을 한 번, 이름을 두 번만 쓰면 돼요. 더 나아졌죠! 하지만 여전히 클래스 이름을 두 번, 각 필드 이름을 두 번 써야 해요. initializing formals은 좋지만, 사용자들은 여전히 충분하지 않다고 말해요.

다른 언어의 기능을 빌려 오는 것에 관하여

다른 언어에서 Dart로 넘어온 사용자들이 특정 기능이 그리워진다고 말하는 걸 볼 수 있어요. 그런 피드백을 우리는 어떻게 처리할까요?

개인적으로 저는 다른 언어의 기능을 빌려 오는 걸 좋아해요. 그 언어의 창작자들이 이미 기능을 설계하고 검증하는 데 많은 노력을 기울였으니까요. 우리는 그들에게서 많은 것을 배울 수 있고, 그 다른 언어 자체가 그 기능이 개념적으로 일관되고 구현 가능하다는 존재 증명이에요.

다른 언어에서 영감을 얻는 것은 우리 언어를 더 배우기 쉽게 만들 수도 있어요. 사용자가 프로그래밍을 완전히 처음이 아니라면, 그들은 Dart를 처음부터 배우지 않아요. 다른 언어에서 이미 배운 모든 것을 갖고 우리에게 오죠. 그들이 배워야 할 남은 것은 그들이 아는 것과 Dart가 담고 있는 것 사이의 차이예요. 다른 언어에서 문법과 시맨틱스를 빌려 오면 그 차이의 크기를 줄이고 Dart를 배우는 수고를 낮춰요.

이 철학은 Dart 성공의 핵심이었어요. 작은 세미콜론부터 클래스까지, Dart는 JavaScript, Java, C# 같은 다른 주류 언어의 사용자에게 익숙하고 배우기 쉽도록 처음부터 끝까지 설계되었어요.

동시에 좋은 언어 설계는 맥락적이고 총체적이에요. "좋은 신발이란 무엇인가?"라는 질문은 북극 툰드라에 서 있을 때와 하와이 해변에 있을 때 정말 다른 답이 나오죠. 예컨대 Rust에서 훌륭하게 작동하는 언어 기능이 고유한 문법, 시맨틱스, 역사, 사용자 기반, 생태계를 가진 Dart에 매끄럽게 들어맞지 않을 수도 있어요.

저는 Dart가 다른 언어에서 뜯어 낸 신체 부위를 꿰맨 프랑켄슈타인의 괴물처럼 느껴지길 바라지 않아요. 그래서 Dart 언어 팀이 다른 언어의 기능을 볼 때는, 그 기능이 그 언어의 맥락에서 문제를 어떻게 해결하는지와 동시에, 그 맥락이 Dart의 맥락과 얼마나 잘 맞는지도 함께 보고 있어요.

Dart에 primary constructors 추가하기

사용자들이 생성자 파라미터에서 일부 필드를 초기화하는 클래스를 정의할 더 나은 표기를 원한다는 걸 우리는 알고 있었어요. primary constructors로는 생성자를 작성하면 컴파일러가 필드를 합성(synthesize)해요. 언어는 반대 방향으로 갈 수도 있어요. 필드 선언을 작성하면 컴파일러가 생성자를 공짜로 주는 방식이죠. Swift는 memberwise initializers로 그렇게 해요.

언어가 하나의 문법에서 두 개의 선언을 유도할 때마다 생기는 도전은, 한 문법이 그 두 선언을 설정할 수 있는 다양한 모든 방식을 처리해야 한다는 거예요. 여기서 인스턴스 필드는 final일 수도 있고 아닐 수도 있어요. @override 같은 메타데이터나 doc 주석을 가질 수도 있죠. 생성자는 이름이 있거나 없을 수 있고, const일 수도 아닐 수도 있어요. 생성자 파라미터는 위치 기반(positional)이거나 이름 기반(named)일 수 있고, 선택적(optional)이거나 필수(required)일 수 있어요. 선택적이라면 기본 값을 지정해야 할 수도 있어요.

우리는 필드 선언에서 생성자를 추론(infer)하는 방식을 한동안 조사했지만, 결국 파라미터가 사용자가 직접 작성하기에 더 유용한 선언이라고 판단했어요. 생성자는 흔히 public API이므로 시그니처를 완전히 제어하는 게 중요해요. 생성자의 이름과 const 여부, 어느 파라미터가 named 또는 positional인지, positional 파라미터의 순서와 기본 값까지요.

생성자 파라미터에서 인스턴스 필드를 추론하려면 사용자가 제공해야 할 빠진 조각은 하나뿐이에요. 필드가 final이어야 하는지 여부죠. 파라미터 앞에 final이나 var을 둘 수 있게 하는 건 꽤 자연스러워요. 두 수식어가 모두 없으면 파라미터는 인스턴스 필드를 전혀 선언하지 않아요. Scala와 Kotlin이 valvar로 하는 것과 비슷하죠. Dart에서 결과는 이렇게 보여요.

class Point(
  final int x,
  final int y,
);

(primary constructors 덕분에 빈 클래스 본문이 더 흔해지므로, 이제 빈 클래스 본문에 {} 대신 ;를 사용할 수도 있게 했어요.)

문법적 절벽(syntactic cliffs)에 관하여

이건 꽤 좋아 보이지만, primary constructor에도 본문이나 initializer list가 필요하다면 어떨까요? 한 가지 선택은 단순히 "그런 경우에는 primary constructor를 쓰지 마세요"라고 말하는 거예요. syntactic sugar는 종종 사용 사례의 부분집합을 골라 그에 더 간결한 문법을 제공해요. 그 부분집합을 벗어나면 더 오래되고 더 자세한 문법으로 돌아가도록 요구하는 게 합리적이죠.

어떤 경우에는 그게 옳은 판단이에요. 하지만 언어 팀은 코드가 시간이 지나면서 진화한다는 점을 아주 유의하고 있어요. 클래스를 작성 중이라고 해 볼게요. 처음에는 생성자 파라미터에서 초기화되는 필드 몇 개로 단순하게 시작해요.

class FormatterOptions({
  final int indent = 0,
  final int pageWidth = 80,
}) {
  // ...
}

primary constructor의 완벽한 사용 사례예요. 나중에 필드와 파라미터를 몇 개 더 추가해요. 좋아요. 어느새 필드를 선언하는 파라미터가 잔뜩 있는 생성자가 생기죠.

class FormatterOptions(
  final int indent = 0,
  final int pageWidth = 80,
  final Version? languageVersion,
  final TrailingCommas? trailingCommas,
  final bool followLinks = false,
  final Show show = Show.changed,
  final Output output = Output.write,
  final Summary summary = Summary.none,
  final bool setExitIfChanged = false,
  final List<String> experimentFlags = const [],
) {
  // ...
}

그러던 어느 날 생성자 본문에서 로그를 조금 남기고 싶어져요. primary constructors가 본문을 지원하지 않는다면, 그 primary constructor 전체를 본문 안(in-body) 생성자로 변환해야 해요.

class FormatterOptions {
  final int indent;
  final int pageWidth;
  final Version? languageVersion;
  final TrailingCommas? trailingCommas;
  final bool followLinks;
  final Show show;
  final Output output;
  final Summary summary;
  final bool setExitIfChanged;
  final List<String> experimentFlags;

  FormatterOptions({
    this.indent = 0,
    this.pageWidth = 80,
    this.languageVersion,
    this.trailingCommas,
    this.followLinks,
    this.show = Show.changed,
    this.output = Output.write,
    this.summary = Summary.none,
    this.setExitIfChanged = false,
    this.experimentFlags = const [],
  }) {
    log.write('Created options.');
  }

  // ...
}

그건 할 만해요. Dart SDK에는 이런 종류의 변경을 버튼 한 번으로 해 주는 quick-fix 도구가 많아서, 기계적으로 코드를 바꾸는 건 어렵지 않아요. 그래도 여전히 큰 텍스트 변경이에요. 로그 한 줄을 추가하고 싶었을 뿐인데, 이제 20줄의 변경을 살펴봐야 하죠.

언어 팀에서는 이것을 "syntactic cliff"(문법적 절벽)이라고 불러요. 작은 시맨틱 변경(여기서는 로그 한 줄 추가)을 하고 싶은데, 표현하고 싶은 것이 최적화된 문법이 지원하는 경계를 살짝 벗어나는 상황이에요. 그 문법의 좋은 평원에서 떨어져 더 장황한 지형에 안착하게 되죠.

그럴 때 기분이 좋지 않아요. 프로그램의 시맨틱 공간을 자유롭게 탐험하려 하는데, 의미상의 작은 걸음 몇 개가 문법상으로는 손이 닿지 않는 것처럼 느껴지거든요. 언어 기능을 설계할 때 우리는 이런 절벽에 관해 많은 시간을 이야기하고, 가능할 때마다 피하려고 해요.

우리는 언어가 완만한 지형처럼 느껴지길 원해요. 작은 시맨틱 변경이 똑같이 작은 텍스트 변경만을 요구하도록 말이죠. 코드 리뷰를 할 때 변경된 줄이 프로그램의 동작 변경을 반영하길 바라지, 언어 문법을 통한 의미 없는 횡적 이동을 반영하길 바라지 않아요.

Primary constructor 본문

Kotlin의 해법은 클래스 본문 안에 initializer block을 두는 거예요. Dart에서는 비슷한 것을 지원하지만 this 키워드를 사용해요.

class FormatterOptions(
  final int indent = 0,
  final int pageWidth = 80,
  final Version? languageVersion,
  final TrailingCommas? trailingCommas,
  final bool followLinks = false,
  final Show show = Show.changed,
  final Output output = Output.write,
  final Summary summary = Summary.none,
  final bool setExitIfChanged = false,
  final List<String> experimentFlags = const [],
) {
  this {
    log.write('Created options.');
  }
}

initializer block은 primary constructor에 본문이나 initializer list를 채울 자리를 줘요. 그것은 생성자에 doc 주석을 다는 자연스러운 장소이기도 해요. (클래스 헤더 위에 doc 주석을 두면 그 주석은 클래스 전체에 적용되지, primary constructor 하나에만 적용되지 않아요.)

이 initializer block들은 일종의 syntactic sugar 위의 syntactic sugar예요. 꼭 필요한 건 아니지만, 사용자가 syntactic cliff에서 떨어지는 걸 막아 줘요. 클래스가 단순할 때 primary constructor로 시작할 수 있고, 언어가 클래스가 진화해도 그 선택에서 벗어나도록 강요하지 않아요.

시맨틱 묶기(semantic bundling)에 관하여

언어가 primary constructor를 in-body 생성자로 바꾸도록 강요하지 않더라도, 여전히 클래스 본문 안에서 생성자를 정의하고 싶을 수 있어요. 클래스는 클래스 헤더에서 많은 걸 담을 수 있어요. 타입 파라미터, extends 절, with 절의 mixin, 그리고 어쩌면 implements까지요. 생성자 파라미터에 doc 주석이 있을 수도 있어요. 위쪽이 지저분하고 어수선해질 수 있죠.

아니면 여러 생성자를 가진 클래스에서 어느 것도 나머지보다 명확히 더 "primary"하지 않을 수도 있어요. (primary constructor가 있으면 그 클래스의 다른 모든 non-factory 생성자는 그쪽으로 리다이렉트해야 해요.) 대안으로, 가장 근본적인 생성자가 private일 때 private 생성자를 눈에 잘 띄는 클래스 헤더에 두는 게 혼란스러워 보일 수도 있어요.

이런 이유 등으로 Dart는 여전히 클래스 본문 안에 선언되는 생성자를 지원해요. 우리는 primary constructors가 in-body 생성자보다 본질적으로 우월하다고 생각하지 않아요. 단지 다르고, 특정 사용 사례에 더 잘 맞을 뿐이에요.

하지만 오직 primary constructor만이 인스턴스 필드를 암묵적으로 선언하고 그 파라미터로 초기화하는, 파라미터 위의 var/final syntactic sugar에 접근할 수 있어요. 이 두 기능(클래스 헤더에서 생성자 선언하기 + 필드를 유도하는 생성자 파라미터)은 함께 묶여 있어요. 후자 없이 전자만 쓸 수는 없죠.

일반적으로 우리는 사용자가 두 동작 중 하나만 원하는 상황에서 언어가 그 둘을 묶어서 둘 다 취하게 하지 않도록 아주 애쓰고 있어요. 예를 들어 클래스 수식어(class modifiers)를 추가할 때 우리는 finalsealed를 의도적으로 함께 추가했어요. 둘은 꽤 비슷하지만, final은 완전성 검사(exhaustiveness checking)에 옵트인하지 않으면서도 서브클래싱을 막을 수 있게 해 줘요.

무엇을 묶을지 결정하는 건 균형 잡기예요. 저는 각 프로그래밍 언어의 성격과 어떤 도메인에 적합한지의 상당 부분이 시맨틱스를 문법에 어떻게 매핑하는지에서 나온다고 믿어요. 그 일부는 여러 동작을 한 조각의 텍스트에 거는 방식이에요. 예를 들어 대부분의 객체지향 언어에서 어떤 클래스를 다른 클래스의 서브클래스로 만드는 것은 정적 타입 시스템에서 서브타입(subtype)으로 만들기도 해요. 엄밀히 필요한 건 아니에요. C++의 private 상속이 보여 주듯이요. 하지만 대부분의 객체지향 언어에서는 그 결합이 말이 되는 것 같아요.

동작과 텍스트의 일대일 매핑이 이상적으로 보일 수도 있지만, 동작을 묶으면 공통 패턴을 표현하기 더 쉬워질 수 있어요. 버거 가게에 들어가서 "2번으로 주세요"라고 말하는 게 "머스터드 넣고 케첩 뺀 더블 치즈버거, 중간 감자튀김, 중간 탄산수"라고 말하는 것보다 얼마나 쉬운지 생각해 보세요.

선언 파라미터(declaring parameters)를 primary constructors와 결합하는 것은 비교적 안전한 묶음이라고 느꼈어요. 선언 파라미터는 그 자체로 syntactic sugar이고, 평범한 클래스 선언에서 이미 표현할 수 있는 것 이상을 표현하게 해 주지 않아요. 어떤 이유로든 생성자를 primary constructor로 만들고 싶지 않다면, 선언 파라미터의 문법적 편의를 포기해야 해요. 하지만 잃는 건 간결함뿐이에요. 정확히 원하는 API와 시맨틱스로 클래스를 정의하는 건 여전히 가능해요.

in-body 생성자 안에서 선언 파라미터를 쓸 수 있으면 좋겠고, 우리는 그것을 허용하는 제안도 작업했어요. 결국 부정적인 결과가 너무 많다고 느꼈어요. 클래스 본문 어딘가의 임의의 생성자가 그 파라미터 목록에서 인스턴스 필드를 암묵적으로 선언할 수 있다면, 클래스가 저장하는 모든 상태를 찾고 추론하는 게 더 어려워져요. primary constructor라면 항상 클래스 맨 위에 있으므로 이 문제가 덜해요.

더 짧은 in-body 생성자

Dart 기존 생성자 선언 문법의 또 다른 장황함의 원인은 클래스 이름을 반복해야 한다는 거예요. Point 같은 짧은 이름의 예시에서는 그리 나빠 보이지 않아요. 하지만 AnimatedFractionallySizedBox 같은 클래스 이름이 있다면, 그 28자짜리 식별자 전체를 반복하는 건 생성자 파라미터 목록에 쓸 수 있는 공간을 많이 차지해요.

그 문제는 고칠 수 있어요. Dart의 생성자 문법은 Java와 C#에서 물려받았고, 그것들은 또 C++에서 물려받았죠. Bjarne Stroustrup은 새 키워드의 수를 최소화하고 생성자 호출이 어떻게 생겼는지 그대로 반영하기 위해 클래스 이름을 쓰기로 선택했어요. 귀여운 문법이지만, 그 자신도 "이건 지나치게 영리했을지도 모른다"고 인정해요.

언어 문법의 한 부분이 다른 부분을 반영하게 하는 건 사용자가 코드가 무엇을 의미하는지 이해하도록 돕는 유용한 도구일 수 있어요. 두 조각의 코드가 같아 보이면, 그것들이 어떤 식으로든 서로 관련되어 있을 가능성이 있다는 신호를 보내니까요. 그리고 선언이 호출부처럼 보이면, 선언을 한 번 읽고 나면 호출하려면 무엇을 써야 하는지 알게 돼요.

적어도 그게 아이디어예요. 하지만 Dart는 이미 그 원칙을 지키지 못하고 있어요. 타입 어노테이션을 무시하면 함수와 메서드 선언에서는 그럭저럭 동작해요. Getter는 호출할 때 get 키워드를 쓰지 않는데도 그 키워드로 선언돼요. 연산자(Operator)는 특별한 operator 키워드로 선언되고 우변 파라미터를 괄호로 감싸는데, 연산자를 호출할 때는 괄호가 필요 없어요. 심지어 함수에서도 Dart는 named 파라미터 선언에 {...}를 쓰는데, 호출부에서 넘길 때의 모습과 전혀 닮지 않았어요.

대부분의 다른 객체지향 언어는 생성자를 정의하는 데 클래스 이름을 쓰지 않아요. Swift, Ruby, Objective-C는 init()을 쓰고, Python은 밑줄을 조금 뿌려서 __init__()을 쓰죠. JavaScript, TypeScript, Kotlin은 constructor를 쓰고, PHP는 __construct를 써요.

이것은 우리가 생성자에 클래스 이름을 반복하는 것이 친숙함이나 일관성 측면에서 그다지 얻는 것이 없다는 결론을 내리게 했어요. 그리고 사용자가 치러야 할 비용도 있어요. 이름은 흔히 장황하고, 모든 인간, 템플릿 프로세서, 코드 생성기, 코드를 만드는 AI 에이전트, 또는 미래의 메타프로그래밍 기능이 클래스에 생성자를 주입하려면 클래스 이름을 사용해야 한다는 걸 알아야 해요. 마치 각 클래스마다 고유한 작은 문맥 키워드가 있는 것 같아요.

그 문제는 우리가 지금 작업하고 있는 또 다른 기능, static extension members에서 특히 두드러져요. Dart의 extension은 기존 타입에 인스턴스 멤버를 붙일 수 있게 해 주지만, 현재는 static 멤버나 생성자를 추가하게 해 주지 않아요. 우리는 그것도 지원하고 싶어요. 하지만 까다로운 엣지 케이스가 생기죠. extension은 클래스뿐 아니라 typedef를 포함한 어떤 정적 타입에도 정의될 수 있어요. 이걸 생각해 봐요.

class SomeClass {}

typedef OtherName = SomeClass;

extension on OtherName {
  // ...
}

그 extension에 생성자를 추가하고 싶다면 어떤 이름을 쓸까요? OtherName일까요, SomeClass일까요? 타입 시스템 관점에서 그것들은 정확히 같은 타입이라는 점을 기억하세요. 프로그램 어디에도 OtherName이라는 클래스는 없어요. typedef는 새 이름 붙은 타입을 만들지 않고, 다른 타입을 가리키는 일시적인 별칭(alias)을 정의할 뿐이에요.

OtherName을 쓰도록 요구할 수도 있어요. 그건 extension 선언의 맨 위에 여러분이 쓴 이름이니까요. 하지만 그건 타입을 같은 타입을 가리키는 typedef로 교체해도 아무것도 깨지지 않는 원칙을 위반해요. 보통 어떤 타입에 대한 참조를 typedef로 교체하는 건 투명한 변경이에요. 여기서, 그리고 오직 여기서만 typedef 이름이 의미를 갖게 되죠.

아니면 반대 방향으로 가서 typedef가 가리키는 기반 클래스의 이름을 쓰라고 할 수도 있어요. 하지만 그건 typedef의 캡슐화를 깨뜨려요. typedef가 다른 라이브러리에 있고 여러분의 라이브러리에서 이름조차 접근할 수 없는 private 클래스를 가리킬 수도 있으니까요!

여기서의 모든 복잡성은 클래스 이름을 "생성자"를 뜻하는 마법의 식별자로 사용한 우리 자신이 만든 문제예요. 보편적인 키워드를 고르기만 하면 그 문제는 사라져요.

그래서 우리가 그렇게 했어요. Dart 3.13에서는 클래스 이름 대신 new를 써서 non-factory 생성자를 선언하고, factory를 써서 factory 생성자를 선언할 수 있어요. named 생성자를 원한다면 newfactory 키워드 뒤에 그 이름을 두면 돼요.

// Before Dart 3.13:
class LongClassName {
  LongClassName();                          // Unnamed constructor.
  LongClassName.create();                   // Named constructor.
}

class AnotherLongClass {
  factory AnotherLongClass() { ... }        // Factory constructor.
  factory AnotherLongClass.create() { ... } // Named factory constructor.
}

// New Dart 3.13 syntax:
class LongClassName {
  new();                                    // Unnamed constructor.
  new create();                             // Named constructor.
}

class AnotherLongClass {
  factory () { ... }                        // Factory constructor.
  factory create() { ... }                  // Named factory constructor.
}

(리다이렉트 생성자의 문법도 비슷해요.)

이것은 우리가 엄격히 더 낫다고 생각하는 syntactic sugar 범주에 속해요. 클래스 이름이 세 글자보다 짧지 않다면 새 문법이 항상 더 짧아요. 더 규칙적이죠. 지금은 낯설다는 느낌(곧 지나갈 감정이에요)을 빼면, 전반적으로 더 낫다고 생각해요.

하지만 new로 생성자를 정의하면 이상한 조합이 하나 생겨요. 그 생성자도 상수(constant)로 만들고 싶다면 const 수식어가 필요해요.

class SomeClass {
  const new() { ... }
}

const new가 모순어법(oxymoron)처럼 보인다는 건 인정해요. 특히 모든 생성자 호출이 newconst로 시작하던 때를 기억하는 오래된 Dart 사용자에게는 더 그렇죠. 그 세계에서 둘은 정반대였어요. 하지만 이제 생성자를 호출할 때 new를 쓰지 않으므로, 제가 지금 생각하는 방식은 new 키워드가 대체로 "생성자를 선언한다"는 뜻이고, const는 항상 "그것을 상수로 만든다"는 뜻의 수식어라는 거예요.

정리

이 긴 설계 과정을 함께 돌아봐 주셔서 감사해요. 우리는 생성자의 시맨틱스와 문법의 모든 세부를 Dart 3.13까지 오는 동안 오래도록 숙고해서, 그것에 대해 또 5,000단어를 쓸 수도 있어요. primary constructor 파라미터 목록을 클래스 헤더의 extends, implements, with 절 뒤에 둘지도 고려했어요. primary constructor 파라미터가 스코프 안에 있어야 하는 위치를 정하기 위해 클래스 본문의 구석구석을 논쟁했죠. 클래스 헤더에서 superclass 호출을 허용해야 할까? with 절에는 어떤 의미가 될까? factory라는 이름의 factory 생성자가 있으면 어떻게 될까?

생성자는 객체지향 프로그래밍의 근본이고, Dart는 이미 생성자에 많은 언어 복잡성을 쏟고 있어요. primary constructors를 Dart에 엮기 위해서는 언어의 직물에 있는 수십 개의 주름을 아주 신중하게 꿰매 정리해야 했어요. 결과가 무난하게(seamless) 느껴지길 바라지만, 완벽하진 않을 거예요.

새 기능의 어느 부분이 이상하거나 자의적으로 느껴진다면, 이 긴 에세이가 그것을 더 이해하게 해 주길 바라요. 그렇지 않다면 알려 주세요. 언어는 항상 진화하고 있고 우리는 항상 더 좋게 만들려고 노력하고 있어요. 그 사이에, 여러분의 Dart 3.13 코드가 더 깔끔하고, 단순하고, 읽고 쓰기에 더 즐거워지길 바랍니다.

더 알아보기