본문 바로가기

Language Syntax/Dart

[Dart] factory 예약어에 대한 고찰

728x90

1. 개요

Dart 에서는 factory 의 예약어가 존재한다. 이것이 어떻게 쓰이는지에 대해 살펴보자.

2. 설명

factory 예약어는 생성자를 정의할 때 사용되고, 객체를 생성하는 과정을 제어할 수 있도록 있도록 하는 특별한 예약어이다.

3. 혼동될 여지가 있는 문법들

Dart 에서 MyClass.name() 와 같이 표현될 수 있는 형태는 여러 가지가 존재한다. 따라서 실제 그 구현부를 확인하지 않고 해당 클래스를 사용하는 경우, 혼란을 야기할 수 있다. 다음의 세 가지 경우가 전술한 형태로 표현된다.

  • 정적 메소드 (Static Method)
  • 명명된 생성자 (Named Constructor)
  • 팩토리 생성자 (factory constructor)

3.1. 정적 메소드 (Static Method)

정적 메소드는 특정 클래스의 객체를 생성하지 않고 직접 클래스 이름을 통해 호출할 수 있는 메소드를 의미한다.

이는 여타 언어들에서도 쉽게 찾아볼 수 있는 대중적 문법이라 할 수 있다.

class MyClass {
  static void name() {}
}

void main() {
  MyClass.name();
}

해당 메소드의 반환값은 변경될 수 있으며, MyClass 타입의 반환도 가능하다.

3.2. 명명된 생성자 (Named Constructor)

명명된 생성자는 객체를 생성할 때 일반 생성자와는 다르게 특정 이름을 붙일 수 있는 특수한 형태의 생성자이다.

class MyClass {
  MyClass.name() {}
}

void main() {
  MyClass.name();
}

형태만 보면 정적 메소드 사용 형태와 차이가 없다. 이는 메소드가 아니며 새로운 객체를 생성하는데, Dart 언어의 특성상 생성자 앞 new 예약어를 생략할 수 있기 때문에 해당 혼란이 더욱 가중될 가능성이 있다.

이와 같은 혼란을 막기 위해 Dart 에서는 기본적으로 정적 메소드와 이름이 동일한 명명된 생성자의 선언을 기본적으로 제한한다.

'name' can't be used to name both a constructor and a static method in this class.

3.3. 팩토리 생성자

factory 예약어를 사용한 생성자 (이하 팩토리 생성자) 는 다음과 같이 나타낼 수 있다.

class MyClass {
  MyClass();
  factory MyClass.name() => MyClass();
}

void main() {
  MyClass.name();
}

위에서 볼 수 있듯이, factory 를 사용하면 팩토리 생성자를 정의할 수 있고, 이 역시 생성자로써 기본 생성자 (MyClass()) 를 소멸시킨다. 따라서 생성자의 정의를 필요로 한다. 4.1.4. 참고

3.4. 정적 메소드로 구현한 팩토리 생성자

팩토리 생성자는 factory 를 사용하지 않고, 정적 메소드를 통하여도 똑같은 형태로 구현될 수 있다.

class MyClass {
  MyClass();
  static MyClass name() => MyClass();
}

void main() {
  MyClass.name();
}

3.3.3.4.MyClass.name() 코드는 같은 기능을 수행한다고 할 수 있다.

4. 사용 이유

그렇다면 factory 예약어가 존재하는 이유가 무엇일까?

4.1. 차이점

4.1.1. 이름이 없는 경우

팩토리 생성자는 factory 를 사용하지 않고, 정적 메소드를 통하여도 똑같은 형태로 구현될 수 있다.

이는 위에서 했던 말인데, 이는 메소드 이름이 있는 경우에 한 한다.

당연하게도 메소드는 이름 없이 선언되지 않는다.

물론 익명함수라는 개념이 존재하지만, 해당 내용과는 결을 달리하므로 무시한다.

class MyClass {
  MyClass();
  static MyClass /* Without Name */() => MyClass();
}

void main() {
  MyClass();
}

즉, 위의 형태는 컴파일 오류를 발생시킨다.


하지만 factory 생성자를 통해서는 가능하다.

class MyClass {
  MyClass._();
  factory MyClass() => MyClass._();
}

void main() {
  MyClass();
}

어딘가 어색한 문법이 쓰였다.

MyClass._(); 는 일종의 명명된 생성자이면서, 접근을 private 으로 제한한 생성자이기도 하다. 따라서 해당 생성자는 외부에서 객체를 생성할 수 없으며 팩토리 생성자를 통해서만 MyClass 객체 생성이 가능해진다.

class MyClass {
  factory MyClass() => MyClass();
}

void main() {
  MyClass();
}

위와 같이 나타내면 되지 않을까 싶지만, 이는 MyClass 자기 자신을 계속해서 호출하다 결국 런타임 오류(Stack Overflow) 를 발생시켜 버린다.

4.1.2. new 생략 여부

class MyClass {
  MyClass();
  factory MyClass.name() => MyClass();
}

void main() {
  new MyClass.name();
}

팩토리 생성자 역시 생성자이기 때문에 new 를 명시하여 나타낼 수 있다.

class MyClass {
  MyClass();
  static MyClass name() => MyClass();
}

void main() {
  new MyClass.name();
}

반면 정적 메소드의 경우, 컴파일 오류를 발생시킨다.

The class 'MyClass' doesn't have a constructor named 'name'.
Try invoking a different constructor, or define a constructor named 'name'.

4.1.3. Generic 타입 매개변수의 표기 여부

class MyClass<T extends num> {
  static MyClass<T> name<T extends num>() => MyClass<T>();
}


void main() {
  MyClass.name<int>();
  MyClass.name<double>();
}

정적 메소드의 경우 해당 클래스가 Generic 을 사용한다면, 타입 매개변수를 모두 명시해주어야 한다.

class MyClass<T extends num> {
  MyClass();
  factory MyClass.name() => MyClass<T>();
}

void main() {
  MyClass<int>.name();
  MyClass<double>.name();
}

하지만 팩토리 생성자를 통해 훨씬 단순화된 이용이 가능하다.

4.1.4. 기본 생성자 존치 여부

class MyClass {
  static MyClass name() => MyClass();
}

void main() {
  MyClass();
  MyClass.name();
}

정적 메소드의 경우, 기본 생성자 (위의 경우 MyClass()) 의 존재에 영향을 미치지 않는다. 즉, 정적 메소드와 기본 생성자 모두 사용할 수 있다.

class MyClass {
  MyClass(); // required
  factory MyClass.name() => MyClass();
}

void main() {
  MyClass();
  MyClass.name();
}

반면, 팩토리 생성자를 정의하는 순간 기본 생성자는 사라지기 때문에 반드시 선언을 필요로 한다.

5. 활용

factory 는 이뿐 아니라 더 많은 곳에 활용될 수 있다.

5.1. 싱글톤 패턴 적용

싱글톤 패턴은 특정 클래스의 객체가 단 한 번만 생성되고, 그 객체에 대한 전역적인 접근 지점을 제공하는 디자인 패턴이다.

즉, 해당 클래스 객체가 최초 1회 생성되고, 그 후에는 이미 생성된 객체를 재사용한다.

5.1.1. 정적 메소드 구현

참조 링크에서는 정적 메소드를 사용하여 Java 언어로 싱글톤 패턴을 구현하였다.

물론 이는 Dart 언어에서도 가능하다.

class Singleton {
  static final Singleton _instance = Singleton._();
  Singleton._();
  static Singleton getInstance() => _instance;
}

void main() {
  Singleton s1 = Singleton.getInstance();
  Singleton s2 = Singleton.getInstance();
  print(s1.hashCode);
  print(s2.hashCode);
}

실행결과

836485291
836485291

5.1.2. 팩토리 생성자 사용

여기에 팩토리 생성자를 사용해보자.

class Singleton {
  static final Singleton _instance = Singleton._();
  Singleton._();
  factory Singleton() => _instance;
}

void main() {
  Singleton s1 = Singleton();
  Singleton s2 = Singleton();
  print(s1.hashCode);
  print(s2.hashCode);
}

실행결과

185343623
185343623

이제 팩토리 생성자를 통해 싱글톤 패턴을 사용할 수 있게 되었다.

5.2. 추상 클래스의 객체 생성

추상 클래스는 기본적으로 객체 생성이 불가하다.

하지만 다음과 같이 나타낼 경우, 팩토리 생성자로 하여금 해당 객체의 구현 객체를 생성할 수 있도록 하기 때문에, 마치 추상 클래스의 객체를 생성한 것처럼 나타낼 수 있다.

abstract class MyClass {
  factory MyClass() = MyConcreteClass;
}

class MyClassImpl implements MyClass {}

void main() {
  MyClass();
}

굳이 이렇게 나타낼 필요가 있을까?

구현 클래스가 두 개 이상 존재하는 예시를 살펴보자.

5.2.1. 예시

abstract class Shape {
  late String name;
  double get area;

  Shape.init(this.name);
  factory Shape(String type, double a, double b) {
    switch (type) {
      case 'ellipse': return Ellipse(a, b);
      case 'rectangle': return Rectangle(a, b);
      case 'triangle': return Triangle(a, b);
      default: throw ArgumentError('Invalid shape type: $type');
    }
  }

  @override
  String toString() => '\n${name.toUpperCase()}\nArea: $area';
}
class Ellipse extends Shape {
  late double xRadius;
  late double yRadius;

  Ellipse(this.xRadius, this.yRadius) : super.init('ellipse');

  @override
  double get area => pi * xRadius * yRadius;
  @override
  String toString() => super.toString() + '\nX Radius: $xRadius, Y Radius: $yRadius';
}

class Rectangle extends Shape {
  late double width;
  late double height;

  Rectangle(
    this.width, 
    this.height,
  ) : super.init('rectangle');

  double get area => width * height;

  @override
  String toString() => super.toString() + '\nWidth: $width, Height: $height';
}

class Triangle extends Shape {
  late double base;
  late double height;

  Triangle(
    this.base, 
    this.height,
  ) : super.init('triangle');

  @override
  double get area => 0.5 * base * height;

  @override
  String toString() => super.toString() + '\nBase: $base, Height: $height';
}
void main() {
  Shape ellipse = Shape('ellipse', 3.0, 2.0);
  Shape rectangle = Shape('rectangle', 4.0, 6.0);
  Shape triangle = Shape('triangle', 3.0, 4.0);

  print(ellipse);
  print(rectangle);
  print(triangle);
}

실행결과


ELLIPSE
Area: 18.84955592153876
X Radius: 3.0, Y Radius: 2.0

RECTANGLE
Area: 24.0
Width: 4.0, Height: 6.0

TRIANGLE
Area: 6.0
Base: 3.0, Height: 4.0

위 코드의 main() 함수를 살펴보면, 추상 클래스인 Shape팩토리 생성자 사용되었고, 해당 생성자는 도형의 종류를 나타내는 문자열 형식의 매개변수를 전달받아 이에 상응하는 객체를 반환한다.

해당 방법이 효용이 높다고 판단하기에 아직 부족하다. 여전히 다음의 코드가 더 유연성, 가독성 측면에서 앞선다고 느껴지기 때문이다.

void main() {
  Shape ellipse = Ellipse(3.0, 2.0);
  Shape rectangle = Rectangle(4.0, 6.0);
  Shape triangle = Triangle(3.0, 4.0);

  print(circle);
  print(rectangle);
  print(triangle);
}

하지만 특정 경우에 따라 상황이 역전될 수 있다.

  • 각 도형에 공통 매개변수를 할당하고자 하는 경우
  • 사용자 입력값으로 도형을 생성할 경우

위 각 상황에 대해 살펴보자.

5.2.2. 각 도형에 공통 매개변수를 할당하고자 하는 경우

가령, 사이즈가 정해진 위젯 안에 도형들을 꽉채워(내접시켜) 표시해야할 경우가 빈번하게 발생한다고 가정하자.

도형의 크기는 위젯의 크기 정보에 종속되므로, 새로운 팩토리 생성자 Shape.widget() 을 정의하여 다음과 같이 구현할 수 있을 것이다.

class Widget {
  double x;
  double y;
  double width;
  double height;

  Widget(this.x, this.y, this.width, this.height);
}
abstract class Shape {
  late String name;
  double get area;

  Shape.init(this.name);
  factory Shape.widget(String type, Widget widget) {
    switch (type) {
      case 'ellipse': return Ellipse(widget.width, widget.height);
      case 'rectangle': return Rectangle(widget.width, widget.height);
      case 'triangle': return Triangle(widget.width, widget.height);
      default: throw ArgumentError('Invalid shape type: $type');
    }
  }

  @override
  String toString() => '\n${name.toUpperCase()}\nArea: $area';
}
void main() {
  Widget widget = Widget(50.0, 50.0, 4.0, 3.0);

  Shape ellipse = Shape.widget('ellipse', widget);
  Shape rectangle = Shape.widget('rectangle', widget);
  Shape triangle = Shape.widget('triangle', widget);

  print(ellipse);
  print(rectangle);
  print(triangle);
}

해당 구현은 도형을 생성하는데 필요한 공통 구현부를 캡슐화하여 코드 재사용성을 높일 수 있다.

5.2.3. 사용자 입력값으로 도형을 생성할 경우

이번에는 사용자가 입력한 데이터에 따라서 도형을 생성해야 한다고 가정하자.

이 경우에도 추상 클래스의 팩토리 생성자를 사용하는 것의 효용을 확인할 수 있다. 다음 두 코드를 비교하여 보자.

코드 #1

void main() {
  List<String> inputArgs = stdin.readLineSync()!.split(' ');
  String type = inputArgs.removeAt(0);
  List<double> args = [for (var s in inputArgs) double.parse(s)];

  late Shape shape;

  switch (type) {
    case 'ellipse': shape = Ellipse(args[0], args[1]); break;
    case 'rectangle': shape = Rectangle(args[0], args[1]); break;
    case 'triangle': shape = Triangle(args[0], args[1]); break;
    default: throw ArgumentError('Invalid shape type: $type');
  }

  print(shape);
}

코드 #2

void main() {
  List<String> inputArgs = stdin.readLineSync()!.split(' ');
  String type = inputArgs.removeAt(0);
  List<double> args = [for (var s in inputArgs) double.parse(s)];

  Shape shape = Shape(type, args[0], args[1]);

  print(shape);
}

사용자가 어떠한 값을 입력할지 알 수 없기 때문에 코드 #1case 문을 작성하여 입력값에 따라 다른 객체를 생성한 반면, 코드 #2에서는 해당 부분을 Shape 클래스의 팩토리 생성자에 위임하여 처리하기 때문에 더 코드가 간결해진다.


관련 포스팅

참고자료

728x90

'Language Syntax > Dart' 카테고리의 다른 글

[Dart] 널 세이프티 (Null Safety)  (0) 2023.11.07
[Dart] 익스텐션 (Extension)  (0) 2023.10.31
[Dart] get / set  (0) 2023.10.31