클래스 수식어
클래스 수식어 (Class modifiers)
Dart에서는 클래스나 믹스인이 어디서 어떻게 쓰일 수 있는지를 수식어(modifier)로 제어할 수 있어요. 즉 정의한 라이브러리 안에서뿐 아니라, 밖에서 가져다 쓸 때 어떤 동작이 가능한지를 선언으로 못 박아 두는 거죠.
본문
수식어 키워드는 class나 mixin 선언 앞에 붙여요. 예를 들어 abstract class라고 쓰면 추상 클래스를 정의하는 식이죠. 클래스 선언 앞에 올 수 있는 수식어는 이렇게 정리할 수 있어요.
abstractbasefinalinterfacesealedmixin
믹스인 선언 앞에는 base 수식어만 붙일 수 있어요. 이 수식어들은 enum, typedef, extension, extension type 같은 다른 선언에는 적용되지 않아요.
수식어를 쓸지 말지 고민할 때는, 그 클래스를 어떤 용도로 쓸 예정인지 그리고 클래스가 믿고 의존해야 할 동작이 무엇인지를 함께 생각해 보는 게 좋아요.
수식어가 없는 경우 (No modifier)
아무 수식어 없이 클래스나 믹스인을 선언하면, 어느 라이브러리에서든 자유롭게 사용할 수 있어요. 기본적으로 이런 것들이 가능하죠.
- 클래스의 새 인스턴스를 만들 수 있어요.
- 클래스를 확장(extend)해 새 하위 타입을 만들 수 있어요.
- 클래스나 믹스인의 인터페이스를 구현(implement)할 수 있어요.
- 믹스인이나 믹스인 클래스를 믹스인할 수 있어요.
abstract
어떤 클래스가 전체 인터페이스에 대한 완전한 구체적 구현 없이도 괜찮게 만들고 싶다면 abstract 수식어를 써요.
추상 클래스는 자기 라이브러리든 다른 라이브러리든 어디서도 인스턴스를 만들 수 없어요. 그리고 추상 클래스는 보통 추상 메서드를 하나 이상 갖고 있어요.
// a.dart
abstract class Vehicle {
void moveForward(int meters);
}
// b.dart
import 'a.dart';
// Error: `Vehicle` can't be instantiated because
// it is marked as `abstract`.
Vehicle myVehicle = Vehicle();
// Can be extended.
class Car extends Vehicle {
int passengers = 4;
@override
void moveForward(int meters) {
// ...
}
}
// Can be implemented.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
// ...
}
}
추상 클래스가 마치 인스턴스화 가능한 것처럼 보이게 하고 싶다면, **팩토리 생성자(factory constructor)**를 정의하면 돼요.
base
클래스나 믹스인의 구현이 상속되도록 강제하고 싶다면 base 수식어를 써요. base 클래스는 자기 라이브러리 밖에서의 구현을 허용하지 않아요. 이렇게 하면 다음이 보장돼요.
- 하위 타입이 이미 같은 이름의 호환되지 않는 시그니처로 멤버를 선언한 경우가 아니라면, 상속이 항상 유지돼요.
base클래스의 생성자는 그 클래스의 하위 타입 인스턴스가 생성될 때마다 호출돼요.- 구현된 모든 비공개(private) 멤버가 하위 타입에 존재해요.
base 클래스를 구현(implement)하거나 확장(extend)하는 클래스는 반드시 base, final, sealed 중 하나로 표시해야 해요. 그래야 외부 라이브러리가 base 클래스의 보장을 깨뜨리지 못하죠.
// a.dart
base class Vehicle {
void moveForward(int meters) {
// ...
}
}
// b.dart
import 'a.dart';
// Can be constructed.
Vehicle myVehicle = Vehicle();
// Can be extended.
base class Car extends Vehicle {
int passengers = 4;
// ...
}
// ERROR: `Vehicle` can't be implemented in a different library because
// it is marked with `base`.
base class MockVehicle implements Vehicle {
@override
void moveForward() {
// ...
}
}
interface
인터페이스를 정의하고 싶다면 interface 수식어를 써요. 인터페이스를 정의한 라이브러리 밖에서는 구현(implement)은 가능하지만 확장(extend)은 할 수 없어요. 이렇게 하면 다음이 보장돼요.
- 클래스의 인스턴스 메서드가
this로 다른 인스턴스 메서드를 호출할 때, 항상 같은 라이브러리에 있는 알려진 구현을 호출하게 돼요. - 다른 라이브러리가 인터페이스 클래스의 메서드가 나중에 예상치 못한 방식으로 호출할 수도 있는 메서드를 오버라이드할 수 없어요. 덕분에 fragile base class 문제가 줄어들어요.
// a.dart
interface class Vehicle {
void moveForward(int meters) {
// ...
}
}
// b.dart
import 'a.dart';
// Can be constructed.
Vehicle myVehicle = Vehicle();
// ERROR: `Vehicle` can't be extended in a different library because
// it is marked with `interface`.
class Car extends Vehicle {
int passengers = 4;
// ...
}
// Can be implemented.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
// ...
}
}
abstract interface
interface 수식어가 가장 흔하게 쓰이는 용도가 바로 **순수 인터페이스(pure interface)**를 정의하는 거예요. interface와 abstract 수식어를 함께 붙여 abstract interface class를 만들 수 있어요.
인터페이스 클래스처럼, 순수 인터페이스는 다른 라이브러리에서 구현은 가능하지만 상속은 할 수 없어요. 그리고 추상 클래스처럼, 순수 인터페이스는 추상 멤버를 가질 수 있어요.
final
타입 계층을 닫아버리고 싶다면 final 수식어를 써요. 이 수식어는 현재 라이브러리 밖에서의 하위 타입화(subtyping)를 막아요. 상속과 구현을 모두 금지하면 하위 타입화 자체가 완전히 사라지죠. 이렇게 하면 다음이 보장돼요.
- 안전하게 API에 점진적인 변경을 추가할 수 있어요.
- 인스턴스 메서드를 호출할 때, 그것이 서드파티 하위 클래스에서 덮어쓰여지지 않았다는 걸 알 수 있어요.
final 클래스는 같은 라이브러리 안에서는 확장하거나 구현할 수 있어요. final 수식어는 base의 효과까지 포함하기 때문에, 하위 클래스 역시 base, final, sealed 중 하나로 표시해야 해요.
// a.dart
final class Vehicle {
void moveForward(int meters) {
// ...
}
}
// b.dart
import 'a.dart';
// Can be constructed.
Vehicle myVehicle = Vehicle();
// ERROR: `Vehicle` can't be extended in a different library
// because it is marked `final`.
class Car extends Vehicle {
int passengers = 4;
// ...
}
// ERROR: `Vehicle` can't be implemented in a different library because
// it is marked `final`.
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
// ...
}
}
sealed
알려진, 열거 가능한 하위 타입 집합을 만들고 싶다면 sealed 수식어를 써요. 이렇게 하면 그 하위 타입들에 대한 switch를 만들 때, 정적으로 완전성(exhaustive)이 보장돼요.
sealed 수식어는 클래스가 자기 라이브러리 밖에서 확장되거나 구현되는 것을 막아요. 그리고 sealed 클래스는 암시적으로 abstract 취급돼요.
- sealed 클래스 자체는 인스턴스화할 수 없어요.
- 팩토리 생성자는 가질 수 있어요.
- 하위 클래스가 사용할 생성자를 정의할 수는 있어요.
다만 sealed 클래스의 하위 클래스는 암시적으로 abstract가 아니라는 점을 기억해 두세요.
컴파일러는 sealed 클래스의 가능한 직계 하위 타입을 전부 알 수 있어요. 왜냐하면 그것들은 같은 라이브러리에만 존재할 수 있기 때문이죠. 덕분에 컴파일러가 switch가 가능한 모든 하위 타입을 빠짐없이 처리하지 못할 때 경고를 띄워줄 수 있어요.
sealed class Vehicle {}
class Car extends Vehicle {}
class Truck implements Vehicle {}
class Bicycle extends Vehicle {}
// ERROR: `Vehicle` can't be instantiated because
// it is marked `sealed` and therefore, implicitly abstract.
Vehicle myVehicle = Vehicle();
// Subclasses of a sealed class can be instantiated unless also restricted.
Vehicle myCar = Car();
extension VehicleSounds on Vehicle {
String get sound {
// ERROR: The switch does not exhaustively account for
// all possible objects of type `Vehicle`.
// In this example, a `Vehicle` with a run-time type of `Bicycle`
// would not match any of the cases.
return switch (this) {
Car() = 'vroom',
Truck() = 'VROOOOMM',
};
}
}
위 예시에서 Bicycle 케이스가 빠져 있기 때문에 컴파일러가 오류를 알려주는 거예요. 완전한 switch를 원하지 않거나, 나중에 API를 깨뜨리지 않고 하위 타입을 추가하고 싶다면 final 수식어를 쓰는 게 낫겠죠. 더 자세한 비교는 'sealed versus final'을 참고해 주세요.
수식어 조합하기 (Combining modifiers)
수식어는 순서가 정해져 있어요. 클래스 선언은 이런 순서로 조합돼요.
- (선택)
abstract— 클래스가 추상 멤버를 가질 수 있는지, 그리고 인스턴스화를 막는지를 나타내요. - (선택)
base,interface,final,sealed중 하나 — 다른 라이브러리가 클래스를 하위 타입화하는 제한을 나타내요. - (선택)
mixin— 선언이 믹스인될 수 있는지를 나타내요. - 클래스 키워드 자체.
다만 몇 가지 조합은 허용되지 않아요.
abstract는sealed와 조합하는데, sealed 클래스가 이미 암시적으로 abstract라서 의미가 없어요.interface,final,sealed는mixin과 조합할 수 없어요. 이 접근 수식어들은 믹스인을 막기 때문이에요.
더 알아보기 (Learn more)
- Dart 공식 문서 - Class modifiers 원문 살펴보기
sealed와final의 차이, 다른 수식어들의 조합 규칙에 대해서는 위 공식 문서의 관련 섹션을 함께 보면 더 깊게 이해할 수 있어요.