iBetter Books
수정

객체를 만드는 순간, 생성자가 실행됩니다. Dart의 생성자는 다른 언어에 비해 유독 선택지가 많습니다. 기본 생성자부터 factory 생성자까지, 각각이 어떤 상황에서 빛을 발하는지 살펴봅니다.

기본 생성자

클래스에 생성자를 따로 쓰지 않으면 Dart가 매개변수 없는 기본 생성자를 자동으로 만들어줍니다. 하지만 직접 선언할 때는 이렇게 씁니다.

// 파일: main.dart
class Point {
  double x;
  double y;

  Point(double x, double y) {
    this.x = x;
    this.y = y;
  }
}

void main() {
  var p = Point(3.0, 4.0);
  print('(${p.x}, ${p.y})');
}

동작은 하지만 반복되는 this.x = x 패턴이 다소 지루합니다. Dart에는 더 간결한 방법이 있습니다.

축약 생성자 (this.필드 패턴)

생성자 매개변수 자리에 this.필드명을 쓰면 자동으로 필드에 대입됩니다. Dart만의 특유한 문법으로, 코드가 눈에 띄게 짧아집니다.

// 파일: main.dart
class Point {
  double x;
  double y;

  Point(this.x, this.y);  // 한 줄로 끝
}

class Person {
  String name;
  int age;
  String email;

  Person(this.name, this.age, this.email);
}

void main() {
  var p = Point(3.0, 4.0);
  print('(${p.x}, ${p.y})');

  var person = Person('홍길동', 25, '[email protected]');
  print('${person.name}, ${person.age}세');
}

this.x를 쓰는 순간 "x라는 이름의 필드에 매개변수 값을 대입하라"는 뜻이 됩니다. 따라서 생성자 바디가 아예 필요 없고, 세미콜론으로 바로 끝낼 수 있습니다.

named 매개변수와 결합하면 더욱 가독성이 좋아집니다.

// 파일: main.dart
class Config {
  String host;
  int port;
  bool ssl;

  Config({
    required this.host,
    this.port = 8080,
    this.ssl = false,
  });
}

void main() {
  var config = Config(host: 'localhost');
  print('${config.host}:${config.port} (ssl: ${config.ssl})');

  var prodConfig = Config(host: 'api.example.com', port: 443, ssl: true);
  print('${prodConfig.host}:${prodConfig.port} (ssl: ${prodConfig.ssl})');
}

이름 있는 생성자 (Named Constructor)

하나의 클래스에 여러 방식으로 인스턴스를 만들고 싶다면 이름 있는 생성자를 씁니다. ClassName.constructorName() 형태입니다.

// 파일: main.dart
class Color {
  int r, g, b;

  Color(this.r, this.g, this.b);

  Color.red() : r = 255, g = 0, b = 0;
  Color.green() : r = 0, g = 255, b = 0;
  Color.blue() : r = 0, g = 0, b = 255;
  Color.white() : r = 255, g = 255, b = 255;
  Color.black() : r = 0, g = 0, b = 0;

  Color.fromHex(String hex)
      : r = int.parse(hex.substring(1, 3), radix: 16),
        g = int.parse(hex.substring(3, 5), radix: 16),
        b = int.parse(hex.substring(5, 7), radix: 16);

  Color.fromJson(Map<String, dynamic> json)
      : r = json['r'] as int,
        g = json['g'] as int,
        b = json['b'] as int;

  @override
  String toString() => 'Color(r:$r, g:$g, b:$b)';
}

void main() {
  var red = Color.red();
  var blue = Color(0, 0, 255);
  var custom = Color.fromHex('#FF6B35');
  var fromData = Color.fromJson({'r': 128, 'g': 0, 'b': 128});

  print(red);      // Color(r:255, g:0, b:0)
  print(blue);     // Color(r:0, g:0, b:255)
  print(custom);   // Color(r:255, g:107, b:53)
  print(fromData); // Color(r:128, g:0, b:128)
}

Color.fromJson처럼 데이터 변환 목적의 이름 있는 생성자는 JSON 파싱에서 자주 등장하는 패턴입니다.

초기화 리스트 (Initializer List)

생성자 바디가 실행되기 전에 필드를 초기화하고 싶다면 초기화 리스트를 씁니다. : 뒤에 필드 = 값 을 쉼표로 구분해서 나열합니다.

// 파일: main.dart
class Rectangle {
  final double width;
  final double height;
  final double area;      // width, height로 계산
  final double perimeter; // width, height로 계산

  Rectangle(double width, double height)
      : width = width,
        height = height,
        area = width * height,
        perimeter = 2 * (width + height);

  @override
  String toString() =>
      'Rectangle(${width}x$height, area: $area, perimeter: $perimeter)';
}

void main() {
  var rect = Rectangle(5, 3);
  print(rect); // Rectangle(5.0x3.0, area: 15.0, perimeter: 16.0)
}

final 필드는 생성자 바디에서 대입할 수 없습니다. 반드시 초기화 리스트나 this.필드 패턴으로 초기화해야 합니다. 초기화 리스트는 바디보다 먼저 실행되므로 final 필드 초기화에 적합합니다.

factory 생성자

일반 생성자는 항상 새 인스턴스를 만듭니다. factory 생성자는 그럴 필요가 없습니다. 기존 인스턴스를 반환하거나, 조건에 따라 다른 타입을 반환하거나, 캐시를 사용하거나, 유효성 검사 후 null을 피해서 예외를 던지는 패턴에 씁니다.

// 파일: main.dart

// 싱글턴 패턴
class AppConfig {
  static AppConfig? _instance;
  String theme;
  String language;

  AppConfig._internal({required this.theme, required this.language});

  factory AppConfig({String theme = 'light', String language = 'ko'}) {
    _instance ??= AppConfig._internal(theme: theme, language: language);
    return _instance!;
  }
}

// 캐시 패턴
class Icon {
  final String name;
  static final Map<String, Icon> _cache = {};

  Icon._internal(this.name);

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

  @override
  String toString() => 'Icon($name)';
}

void main() {
  // 싱글턴: 항상 같은 인스턴스
  var config1 = AppConfig(theme: 'dark', language: 'en');
  var config2 = AppConfig();
  print(identical(config1, config2)); // true

  // 캐시: 같은 이름이면 같은 인스턴스
  var icon1 = Icon('home');
  var icon2 = Icon('home');
  var icon3 = Icon('settings');
  print(identical(icon1, icon2)); // true
  print(identical(icon1, icon3)); // false
}

factory 생성자 안에서는 this를 쓸 수 없습니다. 대신 명시적으로 인스턴스를 반환해야 합니다.

const 생성자

모든 필드가 final이고 컴파일 타임에 값이 확정된다면, const 생성자를 쓸 수 있습니다. const 생성자로 만든 객체는 컴파일 타임 상수가 되어 메모리를 재사용합니다.

// 파일: main.dart
class ImmutablePoint {
  final double x;
  final double y;

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

  double distanceTo(ImmutablePoint other) {
    var dx = x - other.x;
    var dy = y - other.y;
    return (dx * dx + dy * dy) * 0.5; // 간략화
  }

  @override
  String toString() => '($x, $y)';
}

void main() {
  const p1 = ImmutablePoint(0, 0);
  const p2 = ImmutablePoint(0, 0);
  const p3 = ImmutablePoint(1, 1);

  print(identical(p1, p2)); // true  — 같은 객체!
  print(identical(p1, p3)); // false — 값이 다름

  // 컴파일 타임 상수로 사용 가능
  const points = [
    ImmutablePoint(0, 0),
    ImmutablePoint(1, 0),
    ImmutablePoint(1, 1),
  ];
  print(points); // [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]
}

같은 값으로 만든 const 객체는 메모리에서 동일한 인스턴스를 공유합니다. identical(p1, p2)true인 이유입니다. Flutter에서 위젯을 const로 선언하면 리빌드를 줄일 수 있는 것도 이 원리 덕분입니다.

리다이렉팅 생성자

한 생성자가 다른 생성자를 호출하게 하는 패턴입니다. 코드 중복을 줄이는 데 유용합니다.

// 파일: main.dart
class Vector {
  final double x;
  final double y;
  final double z;

  const Vector(this.x, this.y, this.z);

  // 2D 벡터를 만들 때 z는 0으로 고정
  const Vector.xy(double x, double y) : this(x, y, 0);

  // 원점 벡터
  const Vector.zero() : this(0, 0, 0);

  // 단위 벡터
  const Vector.unitX() : this(1, 0, 0);
  const Vector.unitY() : this(0, 1, 0);
  const Vector.unitZ() : this(0, 0, 1);

  @override
  String toString() => 'Vector($x, $y, $z)';
}

void main() {
  var v1 = Vector(1, 2, 3);
  var v2 = Vector.xy(3, 4);
  var v3 = Vector.zero();
  var ux = Vector.unitX();

  print(v1); // Vector(1.0, 2.0, 3.0)
  print(v2); // Vector(3.0, 4.0, 0.0)
  print(v3); // Vector(0.0, 0.0, 0.0)
  print(ux); // Vector(1.0, 0.0, 0.0)
}

: this(...) 가 리다이렉팅입니다. 한 생성자에서 바디 없이 다른 생성자로 위임합니다.

생성자 선택 가이드

어떤 생성자를 써야 할지 정리합니다.

상황 선택
기본적인 초기화 축약 생성자 (this.x)
여러 생성 방식 Named constructor
final 필드 계산 초기화 초기화 리스트
캐시, 싱글턴, 조건 분기 factory
불변 객체, 컴파일 상수 const
다른 생성자 호출 리다이렉팅

다음 챕터에서는 클래스 간의 관계, 즉 상속(inheritance)을 다룹니다. 이미 만들어진 클래스를 확장해서 새로운 클래스를 만드는 방법을 배웁니다.