Dart: Sealed Class에 대하여

Uno·2023년 12월 6일
3

dart

목록 보기
6/7
post-thumbnail

Sealed Class

Dart 3.0 에 추가된 새로운 기능으로 Sealed Class 가 나왔다.

Q. 이 Sealed Class는 왜 나왔는가.

  • Class의 무분별한 상속을 제한하고 싶어서 (Subclassing 제한)
  • 더 안전한 + 더 정확한 패턴 매칭(Pattern-matching)을 하고 싶어서
    (Switch 문이 반드시 모든 타입에 대해서 처리해야함)

Sealed Class 에 대해서 자세히 알기 위해서는 "Union Types" 와 "Pattern-matching" 의 용어에 대해서 알 필요가 있다.

왜 알아야 하나면, Union Types 은 Sealed Class이 달성하려는 목표를 일부 달성하고 있다. Pattern-matching은, Sealed Class를 통해서 구현물이다. 그래서 이 둘을 알 필요가 있다.

Union Type이란?

Union Type은 여러 개의 타입을 합친 타입이다. 그래서 하나의 여러 개의 타입을 다룰 수 있도록 해준다. 그런데 Dart 에는 없다.

먼저 Union 이란, 합집합을 의미한다.

  • A ∪ B

그러므로, Union Type 은 합집합 성질이 있는 타입을 의미하는 것이다.

Typescript 공식문서에서는 다음과 같이 설명하고 있다.

union type은 두 개 이상의 다른 유형으로 구성된 유형으로, 이러한 유형 중 하나가 될 수 있는 값을 나타냅니다. 이러한 각 유형을 유니온의 멤버라고 합니다.

// 여기에 "number | string" 이 두 개의 타입을 모두 포함하는 합집합 타입이 적용되었다.
function printId(id: number | string) { 
	console.log("Your ID is : " + id);
}

// OK
printId(101);

// OK
printId("202");

// Error
// Argument of type '{ myID: number; }' is not assignable to parameter of type 'string | number'.
printId({ myID: 22342 });

그러나, Dart 에서는 union types을 지원하지 않는다. 그래서 Sealed Class 를 통해서 union type 의 목적을 달성한다.

Sealed Class의 특징: 다른 파일에서 접근 불가능

사용법에 대해서, 간단히 알아보고, 패턴매칭을 통한 사용법도 알아보고자 한다.

Sealed class 를 정의하는 방법은 간단하다.

sealed class Vehicle {}

그리고 상속하거나 구현할 수도 있다.

// 상속
class Car extends Vehicle {}

// 구현
class Truck implements Vehicle {}

이 코드가 아래 처럼 되어 있으면 문제가 없다.

// 가정: 아래 코드는 vehicle.dart 라는 하나의 .dart 파일에 모두 있다.
sealed class Vehicle {}

// 상속
class Car extends Vehicle {}

// 구현
class Truck implements Vehicle {}

만약 Truck 클래스만 다른 파일에 있다면, 에러가 발생한다.

// truck.dart 파일에 이 코드만 따로 있다.

// ERROR: The class ‘Truck’ can’t be extended, implemented, or mixed in outside of its library because it’s a sealed class.
// 구현
class Truck implements Vehicle {}

다른 파일에서 sealed class 에 접근할 수 없다.

이 특징으로 인해서, pattern-matching 이라는 기능을 구현할 수 있게 된 것이다. 마치 Union type처럼 동작시킬 수 있는 것이다.

Pattern matching

cf) dart docs : patterns 에 가보면, 다양한 패턴 매칭 방법을 소개하고 있다.

Sealed class의 놀라운 기능은 Pattern matching을 구현하게 될 때, 하나라도 구현하지 않으면 컴파일 에러를 발생시킨다.

이게 무슨뜻인지는 코드를 보면 이해가 쉽다.

// sealed class 정의
sealed class Vehicle {
  void go() {}
}

// 서브 클래스 상속
class Car extends Vehicle {}

// 서브 클래스 채택
class Truck implements Vehicle {
  
  void go() {}
}

// 서브 클래스  상속
class Bicycle extends Vehicle {}
  • 위 코드처럼 각각의 클래스를 정의했다.
String getVehicleSound(Vehicle vehicle) {
	// ERROR: 만약 sealed class에서 특정 서브 클래스 타입을 빼먹었다면(여기서 Biycle)
	// 에러가 발생한다.
	return switch (vehicle) {
		case Car():
			return '부릉부릉';
		case Truck():
			return '그르르를르릉';
		case Bicycle():
			return '띠링띠링';
	};
}
  • 기존의 switch statement 와 다르게 생겼다. case 문이 아니라, 인스턴스 생성의 형태로 되어 있다.
  • 여기서 Bicycle() 부분이 주석되어 있어서 아래와 같은 컴파일 에러가 발생한다.

The type 'Vehicle' is not exhaustively matched by the switch cases since it doesn't match 'Bicycle()'.
Try adding a wildcard pattern or cases that match 'Bicycle()'.

지금 상황은 클래스를 상속했을 뿐인데, 상속했던 모든 클래스를 인지하고 있고, 컴파일 에러를 보여주는 상황이다. 개신기하다.

Sealed Class 용례

학이시습지 불역열호(學而時習之 不亦說乎) 라는 말이 있듯이 배웠으면 써먹는 것이 기쁨을 얻는 것이라고 배웠다.

상황을 하나 가정해보고자 한다. 앱을 구현하다보면 제일 많이 하는 구현이 아마 REST API 연동일 것이다. 과정은 3 단계로 볼 수 있다.

  1. 데이터를 받아오고 있는 LoadingState
  2. 데이터를 모두 받아온 LoadedState
  3. 에러가 발생한 ErrorState

앞으로 이 3 가지 상태를 ListViewState 라는 이름으로 묶을 것이다. 아래 코드를 보자.

// Sealed class를 정의한다.
sealed class ListViewState {}

// LoadingState를 정의한다.
class LoadingState extends ListViewState {}

// LoadingState를 정의한다.
class LoadedState extends ListViewState {
	final String data;
	LoadedState(this.data);
}

class ErrorState extends ListViewState {
	final String message;
	ErrorState(this.message);
}
  • 위 클래스(LoadingState, LoadedState, ErrorState) 3 개의 상태를 상속을 통해 정의한다.
void main() {
	final ListViewState state = LoadedState('완료된 데이터');
	switch(state) {
		case LoadingState():
			print('로딩중');
		case LoadedState():
			print('로딩 완료');
		case ErrorState():
			print('에러 발생!');
	}
}

// 실행결과
// 로딩 완료
  • 위 코드는 우리가 아는 일반적인 패턴매칭이다. Switch ~ Case 를 이용해서 매칭하고 있다.

위 코드에서 만약 하나만 제거하더라도 컴파일에러가 발생한다.

void main() {
	final ListViewState state = LoadedState('완료된 데이터');
	switch(state) { // ERROR
		case LoadingState():
			print('로딩중');
		case LoadedState():
			print('로딩 완료');

	}
}

ERROR: The type 'ListViewState' is not exhaustively matched by the switch cases since it doesn't match 'ErrorState()'. Try adding a default case or cases that match 'ErrorState()'

이렇게 하나라도 빠지면, 바로 에러가 발생한다.

switch 사용법: Expression & Statement

기존 Switch Statement 가 아니라, Switch Expression을 활용하면, 변수를 할당하기도하고, 전달할 수도 있다.

cf) Statement(문장) 과 Expression(표현식) 에 대한 차이 관련 글

  • 문장은 표현식들의 그룹이자 정의문이라고 생각하자.
    - add(num) => print(num); 이것은 정의한 것이다. 그래서 문장이다.
  • 표현식은 값을 가지고 그 값을 실행하기도하고 결과를 반환하기도 하는 식이다.
    - const price = 500; 이것은 값을 할당하고 있는 표현식이다.
void main() {
	final ListViewState state = LoadedState('완료된 데이터');
	final message = switch (state) {
		LoadingState() => '로딩중...',
		LoadedState() => '로딩완료',
		ErrorState() => '에러발생!'
		// 아래처럼 작성도 가능하다.
	// LoadedState() || ErrorState() => '에러발생!'
	};
	print(message); // 로딩완료
}
  • 위 예시는 Switch Expression 의 대표적인 예시다.

파라미터 전달 예시

// Expression
final message = switch (state) {
	LoadingState() => "로딩중...",
	LoadedState(message: String msg) => '다운로드중: $msg',
	ErrorState(message: String msg) => 'ERROR: ${msg}',
};

// Statement
switch (state) {
	case LoadingState():
		print('로딩중');
	case LoadedState(message: String msg):
		print('다운로드중: $msg');
	case ErrorState(message: String msg):
		print('ERRPR: $msg');
}

정리

이것 말고도 Sealed Class 는 사용 방식은 더 있다.

  • Data포맷을 기준으로 Sealed Class 구성하기 : TextData / ImageData / VideoData
  • SocialLogin Type: Naver / Kakao / Google / Apple
    ... etc

사실 Enum의 상위 호환이라고 생각해도 될 것 같다.

Enum은 간단하게 정의하는 개념이라면, Sealed Class 는 Class의 파워를 추가한 Enum 이라고 보면 될 것 같다.

참고 자료


profile
iOS & Flutter

0개의 댓글