생성자

생성자 (Constructors)

Dart에서 생성자는 클래스의 인스턴스를 만드는 특별한 함수예요. Dart에는 여러 종류의 생성자가 있는데, 기본 생성자(default constructor)를 제외하면 모두 자기 클래스와 같은 이름을 사용해요.

출처: Dart 공식 문서 - Constructors

본문

Dart가 제공하는 생성자 종류를 먼저 훑어볼게요.

  • Generative(제네러티브) 생성자 — 새 인스턴스를 만들고 인스턴스 변수를 초기화해요.
  • 기본 생성자 (Default constructors) — 생성자가 따로 지정되지 않았을 때 새 인스턴스를 만드는 데 쓰여요. 인자를 받지 않고 이름도 없어요.
  • 이름 있는 생성자 (Named constructors) — 생성자의 목적을 분명히 하거나, 같은 클래스에 여러 생성자를 만들 수 있게 해줘요.
  • 상수 생성자 (Constant constructors) — 컴파일 타임 상수로 인스턴스를 만들어요.
  • 팩토리 생성자 (Factory constructors) — 하위 타입의 새 인스턴스를 만들거나, 캐시에 있는 기존 인스턴스를 반환해요.
  • 리다이렉팅 생성자 (Redirecting constructors) — 같은 클래스의 다른 생성자로 호출을 넘겨요.

Generative 생성자

클래스를 인스턴스화하려면 generative 생성자를 써요.

class Point {
  // Instance variables to hold the coordinates of the point.
  double x;
  double y;

  // Generative constructor with initializing formal parameters:
  Point(this.x, this.y);
}

기본 생성자 (Default constructors)

생성자를 선언하지 않으면 Dart가 기본 생성자를 사용해요. 기본 생성자는 인자와 이름이 모두 없는 generative 생성자예요.

이름 있는 생성자 (Named constructors)

클래스에 여러 생성자를 구현하거나, 더 명확한 의미를 주고 싶을 때 이름 있는 생성자를 사용해요.

const double xOrigin = 0;
const double yOrigin = 0;

class Point {
  final double x;
  final double y;

  // Sets the x and y instance variables
  // before the constructor body runs.
  Point(this.x, this.y);

  // Named constructor
  Point.origin() : x = xOrigin, y = yOrigin;
}

하위 클래스는 상위 클래스의 이름 있는 생성자를 상속하지 않아요. 상위 클래스에 정의된 이름 있는 생성자로 하위 클래스를 만들고 싶다면, 그 생성자를 하위 클래스에도 직접 구현해야 해요.

상수 생성자 (Constant constructors)

클래스가 만드는 객체가 절대 변하지 않는다면, 그 객체들을 컴파일 타임 상수로 만들 수 있어요. 객체를 컴파일 타임 상수로 만들려면 모든 인스턴스 변수를 final로 설정하고 const 생성자를 정의하면 돼요.

class ImmutablePoint {
  static const ImmutablePoint origin = ImmutablePoint(0, 0);

  final double x, y;

  const ImmutablePoint(this.x, this.y);
}

상수 생성자가 항상 상수를 만드는 건 아니라는 점 기억해 두세요. const가 아닌 문맥에서 호출될 수도 있어요. 자세한 내용은 'using constructors' 섹션을 참고해 주세요.

리다이렉팅 생성자 (Redirecting constructors)

생성자가 같은 클래스의 다른 생성자로 리다이렉트할 수 있어요. 리다이렉팅 생성자는 본문이 비어 있고, 콜론(:) 뒤에 클래스 이름 대신 this를 써요.

class Point {
  double x, y;

  // The main constructor for this class.
  Point(this.x, this.y);

  // Delegates to the main constructor.
  Point.alongXAxis(double x) : this(x, 0);
}

팩토리 생성자 (Factory constructors)

생성자를 구현할 때 다음 두 경우 중 하나를 만나면 factory 키워드를 써요.

  • 인스턴스를 새로 만드는 대신 캐시에서 기존 인스턴스를 반환해야 할 때
  • 하위 타입의 새 인스턴스를 반환해야 할 때

또한 인스턴스를 만들기 전에 하고 싶은 일이 있을 때도 팩토리 생성자가 유용해요. 인자 검사처럼 초기화 리스트(initializer list)로 처리할 수 없는 로직이 여기 해당돼요.

다음 예시에는 팩토리 생성자가 두 개 들어 있어요.

class Logger {
  final String name;
  bool mute = false;

  // _cache is library-private, thanks to
  // the _ in front of its name.
  static final Map<String, Logger> _cache = <String, Logger>{};

  factory Logger(String name) {
    return _cache.putIfAbsent(name, () => Logger._internal(name));
  }

  factory Logger.fromJson(Map<String, Object> json) {
    return Logger(json['name'].toString());
  }

  Logger._internal(this.name);

  void log(String msg) {
    if (!mute) print(msg);
  }
}

위 예시에서 첫 번째 팩토리(Logger)는 _cache라는 캐시에서 객체를 반환하고, 두 번째 팩토리(Logger.fromJson)는 JSON 객체에서 final 변수를 초기화해요.

팩토리 생성자는 다른 생성자와 똑같이 사용하면 돼요.

var logger = Logger('UI');
logger.log('Button clicked');

var logMap = {'name': 'UI'};
var loggerJson = Logger.fromJson(logMap);

리다이렉팅 팩토리 생성자 (Redirecting factory constructors)

리다이렉팅 팩토리 생성자는, 누군가 그 생성자를 호출할 때마다 다른 클래스의 생성자를 호출하도록 지정해요.

factory Listenable.merge(List<Listenable> listenables) = _MergingListenable

일반 팩토리 생성자도 다른 클래스의 인스턴스를 만들어 반환할 수 있으니 리다이렉팅 팩토리가 불필요해 보일 수도 있어요. 하지만 리다이렉팅 팩토리는 몇 가지 장점이 있어요.

  • 추상 클래스가 다른 클래스의 상수 생성자를 사용하는 상수 생성자를 제공할 수 있어요.
  • 리다이렉팅 팩토리 생성자는 포워더가 형식 매개변수와 그 기본값을 반복해서 적는 수고를 덜어줘요.

생성자 tear-off

Dart는 생성자를 호출하지 않고 매개변수처럼 넘길 수 있게 해줘요. 괄호를 떼어낸다고 해서 tear-off라고 부르는데, 이 tear-off는 같은 매개변수로 생성자를 호출하는 클로저 역할을 해요.

tear-off가 메서드가 받아들이는 것과 같은 시그니처와 반환 타입을 가진 생성자라면, 그 생성자를 매개변수나 변수로 사용할 수 있어요.

tear-off는 람다나 익명 함수와는 달라요. 람다는 생성자의 래퍼 역할을 하지만, tear-off는 생성자 그 자체예요. 그래서 tear-off 사용을 권장해요.

// Use a tear-off for a named constructor:
var strings = charCodes.map(String.fromCharCode);

// Use a tear-off for an unnamed constructor:
var buffers = charCodes.map(StringBuffer.new);

람다를 쓰는 대신 이렇게 쓰면 돼요.

// Instead of a lambda for a named constructor:
var strings = charCodes.map((code) => String.fromCharCode(code));

// Instead of a lambda for an unnamed constructor:
var buffers = charCodes.map((code) => StringBuffer(code));

tear-off에 대한 더 자세한 설명은 Decoding Flutter 영상을 참고해 주세요.

간결한 생성자 문법 (Concise constructor syntax)

Dart 3.13 이상에서는, 클래스 본문 안에서 generative 또는 factory 생성자를 선언할 때 클래스 이름을 생략할 수 있어요. newfactory 수식어를 바로 쓰면 되죠.

  • new 또는 new name — 이름 없는/이름 있는 generative 생성자
  • factory 또는 factory name — 이름 없는/이름 있는 factory 생성자

전통적인 이름 있는 생성자(Point.origin() 같은)와 달리, 간결한 이름 있는 생성자는 키워드(수식어)와 이름 사이에 점(dot)을 쓰지 않아요.

class Point {
  double x, y;

  // Concise unnamed generative constructor.
  new(this.x, this.y);

  // Concise named generative constructor.
  new origin() : x = 0, y = 0;

  // Equivalent to `factory Point.clone(Point other)`.
  factory clone(Point other) = Point(other.x, other.y);
}

LongClassName이라는 클래스를 기준으로, 전통적인 생성자 문법을 간결한 문법으로 어떻게 바꾸는지 정리한 표예요.

전통적인 Dart 문법 간결한 문법
LongClassName() {} new() {}
LongClassName.name() {} new name() {}
const LongClassName(); const new();
const LongClassName.name(); const new name();
LongClassName(): this.other(); new(): this.other();
LongClassName.name(): this(); new name(): this();
const LongClassName(): this.other(); const new(): this.other();
const LongClassName.name(): this(); const new name(): this();
factory LongClassName() { ... } factory() { ... }
factory LongClassName.name() { ... } factory name() { ... }
factory LongClassName() = D; factory() = D;
factory LongClassName.name() = D; factory name() = D;
const factory LongClassName() = D; const factory() = D;
const factory LongClassName.name() = D; const factory name() = D;

이 문법은 장황함을 줄여주고, 클래스 이름을 리팩터링하기도 더 쉽게 해줘요. 필드와 생성자를 한 줄에 정의하는 더 간결한 문법은 'Primary constructors'를 참고해 주세요.

인스턴스 변수 초기화 (Instance variable initialization)

Dart는 인스턴스 변수를 초기화하는 여러 방법을 제공해요. 선언할 때 값을 할당하거나, initializing formal parameters(초기화 형식 매개변수)를 쓰거나, 초기화 리스트(initializer list)를 쓰는 식이죠.

선언할 때 인스턴스 변수 초기화하기

변수를 선언할 때 인스턴스 변수를 초기화할 수 있어요.

class PointA {
  double x = 1.0;
  double y = 2.0;

  // The implicit default constructor sets these variables to (1.0,2.0)
  // PointA();

  @override
  String toString() {
    return 'PointA($x,$y)';
  }
}

initializing formal parameters 사용하기

생성자 인자를 인스턴스 변수에 할당하는 흔한 패턴을 간단히 하기 위해, Dart는 initializing formal parameters를 제공해요. 생성자 선언에 this.propertyName을 포함하고 본문을 생략하면 되죠. 여기서 this는 현재 인스턴스를 가리켜요.

이름 충돌이 있을 때는 this를 쓰고, 그렇지 않으면 Dart 스타일은 this를 생략해요. 단 한 가지 예외가 있는데, generative 생성자에서는 initializing formal parameter 이름 앞에 반드시 this.를 붙여야 해요.

앞서 언급했듯, 어떤 생성자들은 그리고 생성자의 일부 부분들은 this에 접근할 수 없어요. 다음과 같은 경우가 해당돼요.

  • 팩토리 생성자
  • 초기화 리스트의 오른쪽(RHS)
  • 상위 클래스 생성자의 인자

initializing formal parameters는 non-nullable이거나 final인 인스턴스 변수도 초기화할 수 있게 해줘요. 두 유형 모두 초기화나 기본값이 필요하죠.

class PointB {
  final double x;
  final double y;

  // Sets the x and y instance variables
  // before the constructor body runs.
  PointB(this.x, this.y);

  // Initializing formal parameters can also be optional.
  PointB.optional([this.x = 0.0, this.y = 0.0]);
}

이 방식은 이름 있는 변수(named variables)에서도 동작해요.

class PointC {
  double x; // must be set in constructor
  double y; // must be set in constructor

  // Generative constructor with initializing formal parameters
  // with default values
  PointC.named({this.x = 1.0, this.y = 1.0});

  @override
  String toString() {
    return 'PointC.named($x,$y)';
  }
}

// Constructor using named variables.
final pointC = PointC.named(x: 2.0, y: 2.0);

initializing formal parameters에서 도입된 모든 변수는 final이며 초기화되는 변수의 스코프 안에서만 존재해요.

초기화 리스트로는 표현할 수 없는 로직을 수행해야 한다면, 그 로직을 담은 팩토리 생성자static 메서드를 만들면 돼요. 그런 다음 계산된 값을 일반 생성자에 넘기면 되죠.

또한 생성자 매개변수를 nullable로 설정해 초기화를 피할 수도 있어요.

class PointD {
  double? x; // null if not set in constructor
  double? y; // null if not set in constructor

  // Generative constructor with initializing formal parameters
  PointD(this.x, this.y);

  @override
  String toString() {
    return 'PointD($x,$y)';
  }
}

비공개 이름 있는 매개변수 (Private named parameters)

Dart에서 밑줄로 시작하는 필드는 자기 라이브러리 안에서만 private해요. 이름 있는 매개변수로 private 필드를 초기화하려면, 초기화 리스트에 수동 할당 보일러플레이트를 쓸 수 있어요.

class Point {
  final double _x;
  Point({required double x}) : _x = x;
}

private 필드를 생성자 매개변수 리스트에서 직접 초기화할 수도 있어요. 이름 있는 매개변수 앞에 this._를 붙이면, 컴파일러가 호출자 쪽에서는 밑줄을 자동으로 떼어줘서 깔끔한 공개 이름을 쓸 수 있게 해줘요.

class Point {
  final double _x;
  Point({required this._x});
}

두 경우 모두 호출자는 호출 지점(call site)에서 공개 이름 x를 사용해요.

var p = Point(x: 1.0);

일반 이름 있는 매개변수처럼, private 이름 있는 매개변수도 선택적(optional) 또는 필수(required)로 만들 수 있고, 명시적 기본값도 줄 수 있어요.

다음 예시에서 _x 매개변수는 선택적이고 기본값은 null이에요. _y 매개변수도 선택적이지만 명시적 기본값 0.0을 가져요.

class PointPrivate {
  final double? _x; // Nullable field
  final double _y; // Non-nullable field

  PointPrivate({this._x, this._y = 0.0});

  @override
  String toString() => 'PointPrivate($_x, $_y)';
}

void testPrivate() {
  var p = PointPrivate(x: 1.0, y: 2.0);
  print(p);
}

제약 사항 (Constraints)

  • 충돌 없음: private 이름이나 생성된 공개 이름 모두, 같은 생성자의 다른 매개변수 이름과 겹치면 안 돼요.
  • initializing formals만: 일반적으로 Dart의 이름 있는 매개변수는 private일 수 없어요. 이 기능은 initializing formal(this._field)인 이름 있는 매개변수에만 적용되는 예외예요. 일반 이름 있는 매개변수에 private 식별자를 쓸 수는 없어요.
  • 유효한 공개 이름: private 이름은 유효한 공개 식별자에 대응해야 해요. 예를 들어 this._this._2x는 유효한 공개 대응이 없으므로 잘못된 사용이에요.

초기화 리스트에서의 사용 (Usage in initializer lists)

생성자의 초기화 리스트 안에서는 매개변수를 private 이름으로 참조해요.

class PointPrivateAssert {
  final double _x;

  PointPrivateAssert({required this._x}) : assert(_x >= 0);
}

super 매개변수와의 상호작용

private 이름 있는 매개변수를 사용하는 클래스를 확장할 때, 하위 클래스는 super 매개변수에 대해 공개 이름을 사용해요. super 매개변수는 상위 클래스 생성자에 인자를 자동으로 전달하는 생성자 매개변수예요.

다음 예시에서 Tool 클래스는 private 필드 _price를 정의해요. 필드가 private임에도 그에 해당하는 이름 있는 매개변수는 공개(price, _price 아님)예요. 값을 전달하기 위해 Hammer 하위 클래스는 공개 식별자 price를 사용해요.

class Tool {
  final int _price;
  Tool({required this._price});
}

class Hammer extends Tool {
  // Forwards to the public 'price' argument
  Hammer({required super.price});
}

초기화 리스트 사용하기 (Use an initializer list)

생성자 본문이 실행되기 전에 인스턴스 변수를 초기화할 수 있어요. 초기화식들은 쉼표로 구분해요.

// Initializer list sets instance variables before
// the constructor body runs.
Point.fromJson(Map<String, double> json)
    : x = json['x']!,
      y = json['y']! {
  print('In Point.fromJson(): ($x, $y)');
}

개발 중에 입력값을 검증하고 싶다면 초기화 리스트에서 assert를 사용해요.

Point.withAssert(this.x, this.y) : assert(x >= 0) {
  print('In Point.withAssert(): ($x, $y)');
}

초기화 리스트는 final 필드를 설정할 때 특히 도움이 돼요. 다음 예시는 초기화 리스트에서 final 필드 세 개를 초기화해요.

import 'dart:math';

class Point {
  final double x;
  final double y;
  final double distanceFromOrigin;

  Point(double x, double y)
    : x = x,
      y = y,
      distanceFromOrigin = sqrt(x * x + y * y);
}

void main() {
  var p = Point(2, 3);
  print(p.distanceFromOrigin);
}

생성자 상속 (Constructor inheritance)

하위 클래스(자식 클래스)는 상위 클래스(직계 부모 클래스)에서 생성자를 상속하지 않아요. 클래스가 생성자를 선언하지 않으면 기본 생성자만 사용할 수 있어요.

클래스가 상위 클래스의 매개변수를 상속할 수는 있는데, 이를 super 매개변수라고 불러요.

생성자는 static 메서드 체인을 호출하는 방식과 비슷하게 동작해요. 각 하위 클래스는 인스턴스를 초기화하기 위해 상위 클래스의 생성자를 호출할 수 있죠. 이 과정은 생성자 본문이나 시그니처를 '상속'하는 게 아니에요.

기본이 아닌 상위 클래스 생성자 (Non-default superclass constructors)

Dart는 생성자를 다음 순서로 실행해요.

  1. 초기화 리스트 (initializer list)
  2. 상위 클래스의 이름 없는, 인자 없는 생성자
  3. 메인 클래스의 인자 없는 생성자

상위 클래스에 이름 없는 인자 없는 생성자가 없다면, 상위 클래스의 생성자 중 하나를 호출해야 해요. 생성자 본문(있다면)보다 앞서 콜론(:) 뒤에 상위 클래스 생성자를 지정하면 되죠.

다음 예시에서 Employee 클래스 생성자는 상위 클래스 Person의 이름 있는 생성자를 호출해요.

class Person {
  String? firstName;

  Person.fromJson(Map data) {
    print('in Person');
  }
}

class Employee extends Person {
  // Person does not have a default constructor;
  // you must call super.fromJson().
  Employee.fromJson(Map data) : super.fromJson(data) {
    print('in Employee');
  }
}

void main() {
  var employee = Employee.fromJson({});
  print(employee);
  // Prints:
  // in Person
  // in Employee
  // Instance of 'Employee'
}

Dart는 상위 클래스 생성자를 호출하기 전에 그 인자를 평가하므로, 인자는 함수 호출 같은 표현식이 될 수도 있어요.

class Employee extends Person {
  Employee() : super.fromJson(fetchDefaultData());
  // ···
}

Super 매개변수 (Super parameters)

생성자의 super 호출에 각 매개변수를 일일이 전달하지 않으려면, super-initializer 매개변수를 사용해 매개변수를 지정된(또는 기본) 상위 클래스 생성자로 전달하면 돼요. 이 기능은 리다이렉팅 생성자와는 함께 쓸 수 없어요. super-initializer 매개변수는 initializing formal parameters와 비슷한 문법과 의미를 가져요.

super 생성자 호출에 위치 인자(positional arguments)가 포함되면, super-initializer 매개변수는 위치 인자가 될 수 없어요.

class Vector2d {
  final double x;
  final double y;

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

class Vector3d extends Vector2d {
  final double z;

  // Forward the x and y parameters to the default super constructor like:
  // Vector3d(final double x, final double y, this.z) : super(x, y);
  Vector3d(super.x, super.y, this.z);
}

이해를 위해 한 가지 예시를 더 볼게요. super 생성자를 위치 인자(super(0))로 호출하면, super 매개변수(super.x)를 쓰면 오류가 나요.

  // If you invoke the super constructor (`super(0)`) with any
  // positional arguments, using a super parameter (`super.x`)
  // results in an error.
  Vector3d.xAxisError(super.x): z = 0, super(0); // BAD

이 이름 있는 생성자는 x 값을 두 번 설정하려고 해요. 한 번은 super 생성자에서, 한 번은 위치 super 매개변수로요. 둘 다 x라는 위치 매개변수를 다루므로 오류가 나는 거예요.

super 생성자에 이름 있는 인자가 있으면, 그것을 이름 있는 super 매개변수(다음 예시의 super.y)와 super 생성자 호출의 이름 있는 인자(super.named(x: 0))로 나눠 쓸 수 있어요.

class Vector2d {
  // ...
  Vector2d.named({required this.x, required this.y});
}

class Vector3d extends Vector2d {
  final double z;

  // Forward the y parameter to the named super constructor like:
  // Vector3d.yzPlane({required double y, required this.z})
  //       : super.named(x: 0, y: y);
  Vector3d.yzPlane({required super.y, required this.z}) : super.named(x: 0);
}

더 알아보기 (Learn more)

  • Dart 공식 문서 - Constructors 원문 살펴보기
  • Primary constructors, 생성자 사용법의 더 자세한 내용은 위 공식 문서의 관련 섹션을 함께 보면 좋아요.