티스토리 뷰
다트 3.0에서는 클래스와 믹스인 선언에 적용할 수 있는 몇 가지 새로운 수정자가 추가되었다. 이러한 수정자는 외부의 사용 권한을 제어하게 된다.
이 가이드는 이러한 변경 사항을 설명하여 새로운 수정자를 사용하는 방법과 라이브러리 사용자에게 어떤 영향을 미치는지 알려준다.
클래스에서의 믹스인 수정자
가장 중요한 수정자는 mixin이다. 다트 3.0 이전 버전에서는 다음 조건이 없는 한 어떤 클래스도 다른 클래스의 with 절에 믹스인으로 사용할 수 있었다.
- 생성자를 선언하지 않음
- Object가 아닌 다른 클래스를 확장하지 않음
이로 인해, 다른 사람이 with 절에서 해당 클래스를 사용하는 것을 모르고, 클래스에 생성자나 확장을 추가함으로써 다른 사람의 코드르르 실수로 깨뜨릴 수 있다.
다트 3.0에서는 기본적으로 클래스를 믹스인으로 사용할 수 없다. 대신, 명시적으로 믹스인 클래스로 선언하여 사용할 수 있다.
mixin class Both {}
class UseAsMixin with Both {}
class UseAsSuperclass extends Both {}
믹스인? 믹스인 클래스?
클래스에 팩토리 생성자가 아닌 생성자, extends 절 또는 with 절이 있는 경우 믹스인으로 사용할 수 없다. 다트 3.0에서도 이 내용이 변경되지 않으므로 걱정할 필요가 없다.
실제로, 이는 기존 클래스의 90%에 해당한다. 믹스인으로 사용할 수 있는 나머지 클래스에 대해서는 무엇을 지원할지 결정해야 한다.
- 사용자의 부주의로 인한 위험을 감수하고 싶은가? 만약 '아니요'라면, 믹스인으로 사용할 수 있는 모든 클래스 앞에 mixin을 추가하자. 이는 API의 기존 동작을 정확히 유지한다.
반면에, 이 기회를 통해 API가 제공하는 기능을 재고하고 싶다면, 이를 믹스인 클래스로 만들지 않을 수도 있다. 다음 두 가지 설계 질문을 고려해 보자.
- 사용자가 인스턴스를 직접 생성할 수 있기는 원하는가? 즉, 클래스가 추상이 아니길 원하는가?
- 선언을 믹스인으로 사용할 수 있기를 원하는가? 즉, 사용자가 이를 with 절에 사용할 수 있기를 원하는가?
둘 다 "예"라면, 이를 믹스인 클래스로 만들자.
두 번째 질문만 "아니요"라면 클래스로 남겨두자.
첫 번째 질문만 "아니요"라면 믹스인으로 만들자.
클래스로 남기거나, 믹스인으로 변경하는 것은 API를 깨뜨리는 변경 사항이다. 이를 수행하면 패키지와 주요 버전을 증가시켜야 한다.
다른 추가 수정자
클래스를 믹스인으로 처리하는 것이 다트 3.0에서 패키지의 API에 영향을 미치는 유일한 변경 사항이다. 여기까지 진행한 훼 사용자가 패키지를 사용할 수 있는 방식에 다른 변경을 원하지 않는다면 여기서 멈춰도 된다.
하지만 계속 진행하여 아래 설명된 수정자 중 하나를 사용한다면, 이는 패키지의 API 호환성을 깨뜨릴 수 있으니, 주 버전을 증가시켜야 한다.
interface 수정자
이전 다트에는 순수 인터페이스를 선언하는 별도의 구문이 없다. 대신 추상 클래스로 선언하고 추상 메서드만 포함하도록 한다. 사용자는 해당 클래스를 상속하여 재사용할 수 있는 코드가 포함되어 있는지 아니면 인터페이스로 사용해야 하는지 알 수 없다. 상속 불가는 다른 패키지는 물론 같은 패키지 내의 다른 라이브러리에도 적용된다.
하지만 인터페이스 클래스는 자신의 라이브러리 내부에서는 자신의 코드이기 때문에 확장이 자유롭다.
클래스에 인터페이스 수정자를 추가하면 이를 명확히 할 수 있다. 이는 클래스를 implements 절에서 사용할 수 있게 하지만 extends에서는 사용할 수 없게 한다.
클래스에 비-추상 메서드가 포함되어 있어도 사용자가 상속하지 못하게 하고 싶을 수 있다. 상속은 코드를 재사용할 수 있게 하는 강력한 결합 형태지만, 상위 클래스가 수정되면 하위 클래스가 깨지지 않도록 유지하기가 어렵다는 단점이 발생한다.
클래스를 interface로 표시하면 인스턴스를 생성할 수도 있다. 하지만 인터페이스 클래스를 with 절에 사용해서 인터페이스로 구현할 수 있지만 코드를 재정의해야 한다.
base 수정자
베이스 수정자는 클래스를 extends 절로 확장은 가능하나 with 절에서 믹스인 클래스처럼 사용할 수 있게 한다. 그러나 해당 클래스의 라이브러리 외부에서는 클래스를 implements절에서 사용하는 것을 금지한다.
이는 실제 구현을 상속받도록 보장한다. 특히, 이는 모든 비공개 멤버도 포함된다. 이를 통해 발생할 수 있는 런타임 오류를 방지할 수 있다.
다음 같은 라이브러리가 있다고 생각해 보자. 이 코드는 자체적으로 문제가 없다.
// a.dart
class A {
void _privateMethod() {
print('I inherited from A');
}
}
void callPrivateMethod(A a) {
a._privateMethod();
}
하지만 사용자가 다음과 같은 라이브러리를 생성하는 것을 방지할 수 없으며, 이 코드는 에러가 발생하게 된다.
b.dart
import 'a.dart';
class B implements A {
// _privateMethod()의 구현이 없다!
}
main() {
callPrivateMethod(B()); // 런타임 예러!
}
클래스에 베이스 수정자를 추가하면 이러한 런타임 오류를 방지할 수 있다.
// a.dart
base class A {
void _privateMethod() {
print('I inherited from A');
}
}
void callPrivateMethod(A a) {
a._privateMethod();
}
하지만 동일한 라이브러리 내에서는 이 제한이 무시된다. 동일한 라이브러리 내의 하위 클래스는 비공개 메서드를 구현하라고 컴파일러가 알려준다.
class A {
void _privateMethod() {
print("from A");
}
}
void callPrivateMethod(A a) {
a._privateMethod();
}
class B implements A {
// A._privateMethod 구현 누락을 알려줌
}
base의 전이성
클래스에 base 수정자를 사용하는 목적은 해당 타입의 비공개도 포함한 모든 멤버를 상속받도록 보장하는 것이다. 이를 유지하기 위해 베이스 제한은 '전이성'을 가진다. base로 표시된 타입의 모든 하위 타입도 상속을 방지해야 한다. 그래서 base 하위 타입은 base, final 또는 sealed가 되어야 한다.
타입에 베이스를 적용하는 것을 주의를 요한다. 이는 사용자가 클래스 또는 믹스인으로 할 수 있는 일뿐만 아니라, 하위 클래스가 제공할 수 있는 기능에도 영향을 미친다. 타입에 베이스를 적용하면 해당 하위 계층 전체가 구현되는 것을 금지한다.
final 수정자
final 수정자를 사용하면, 해당 클래스는 implements, extends, with 또는 on절에서도 사용할 수 없다.
이는 클래스 사용자에게 가장 제한적이다. 사용자는 인스턴스화할 수 있을 뿐이다. 만약 abstract도 표시되어 있으면 인스턴스화도 할 수 없다. 그 대신, 클래스 유지 관리자는 가장 적은 제한을 받는다. 새로운 메서드를 추가하거나 생성자를 팩토리 생성자로 변경하는 등의 작업을 하여도 하위 타입이 없기 때문에 상속으로 인한 문제를 걱정할 필요가 없다.
sealed 수정자
sealed 수정자는 주로 패턴 매칭에서 포괄성 검사(exhaustiveness checking)를 가능하게 하기 위해 존재한다. 만약 sealed로 표시된 타입의 모든 하위 타입에 대한 케이스가 switch 문에 포함되어 있는지를 컴파일러는 확인할 수 있다.
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
String lastName(Amigo amigo) => switch (amigo) {
Lucky _ => 'Day',
Dusty _ => 'Bottoms',
Ned _ => 'Nederlander',
};
위 switch 문은 Amigo의 모든 하위 타입에 대한 케이스를 포함하고 있다. 컴파일러는 Amigo의 모든 인스턴스가 이 하위 타입 중 하나의 인스턴스임을 알기 때문에, 이 switch 문은 안전하며, 최종적으로 default 케이스가 필요하지 않다는 것을 알 수 있다.
이 기능을 안전하게 하기 위해, 컴파일러는 두 가지 제한을 강제한다.
- sealed 클래스는 직접적으로 인스턴스화될 수 없다. 그렇지 않으면 Amigo의 인스턴스가 하위 타입 중 어느 것에도 해당하지 않을 수 있다. 따라서 모든 sealed 클래스는 암시적으로 추상 클래스이기도 한다.
- sealed 타입의 모든 직접적인 하위 타입은 sealed 타입이 선언된 동일한 라이브러리 내에 있어야 한다. 이렇게 함으로써, 컴파일러는 모든 하위 타입을 찾을 수 있다.
두 번째 제한은 final과 유사하다. final과 마찬가지로, sealed로 표시된 클래스는 선언된 라이브러리 외부에서 직접 확장, 구현 또는 믹스인 될 수 없다. 그러나 base나 final과 달리, 전이적인 제한은 없다.
// amigo.dart
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
// other.dart
// 오류 발생:
class Bad extends Amigo {}
// 그러나 다음 두 가지는 모두 허용됩니다:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}
물론, sealed 타입의 하위 타입도 제한하고 싶다면, interface, base, final 또는 sealed를 사용하여 이들을 표시할 수 있다.
sealed vs final
사용자가 클래스를 직접 하위 타입으로 만들지 않기를 원한다면, sealed와 final 중 무엇을 사용해야 할까?
- 사용자가 클래스의 인스턴스를 직접 생성할 수 있기를 원한다면 sealed 타입은 암시적으로 추상이기 때문에 sealed를 사용할 수 없다.
- 라이브러리에 하위 타입이 없다면, 포괄성 검사 이점을 얻으르 수 없으므로 sealed를 사용할 필요가 없다.
- 클래스에 정의된 몇 가지 하위 타입이 있다면, sealed가 필요할 수 있다. 사용자가 클래스에 몇 가지 하위 타입이 있는 것을 보면, 각 타입을 개별적으로 처리할 수 있도록 하는 것이 편리하며, 컴파일러가 전체 타입을 사용하는지 체크하는 것을 알 수 있다.
sealed를 사용하면 나중에 라이브러리에 새로운 하위 타입을 추가할 때, API 변경 사항이 발생한다. 새로운 하위 타입이 나타나면, 기존의 모든 switch 가 포괄적이지 않게 된다. 이는 열거형(enum)에 새로운 값을 추가하는 것과 정확히 같다.
포괄적이지 않는 switch 컴파일 오류는 사용자가 새 타입을 처리해야 하는 코드 위치를 알려주기 때문에 유용하다.
그러나 새로운 하위 타입을 추가할 때마다 변경 사항이 발생한다. 새로운 하위 타입을 자유롭게 추가하길 원한다면, switch 문에 default 케이스를 추가된다. 그러면 switch 문은 포괄성 검사를 하지 않고 새로운 하위 타입이 추가되어도 케이스가 없으면 default 케이스가 실행된다.
요약
API 설계자로서, 이러한 새로운 수정자는 사용자가 코드와 상호 작용하는 방식과 코드를 발전시키는 방식에 대한 제어를 제공한다.
하지만 이러한 옵션은 복잡성을 수반한다. 이제 API 설계자로서 선택해야 할 사항이 더 많아졌다. 또한, 이 기능들이 새로 추가되었기 때문에 아직 최선의 방법이 무엇인지 확립되지 않았다. 각 언어의 생태계는 다르고, 각기 다른 요구 사항을 가지고 있다.
다행히도, 모든 것을 한꺼번에 이해할 필요는 없다. 다트는 기본값을 신중하게 선택했기 때문에 아무것도 하지 않아도 대부분의 클래스는 3.0 이전과 거의 동일한 사용성을 가진다. API를 이전과 동일하게 유지하고 싶다면, 이미 그 용도로 사용되었던 클래스에 mixin을 붙이면 된다.
시간이 지나면서 더 세밀한 제어가 필요하다고 느껴질 때, 다음과 같은 수정자들을 고려할 수 있다.
- interface : 사용자가 클래스의 코드를 재사용하지 못하게 하면서도 인터페이스를 재구현할 수 있도록 허용한다.
- base : 사용자가 클래스의 코드를 재사용하도록 요구하며, 클래스 타입의 모든 인스턴스가 실제 클래스나 하위 클래스의 인스턴스임을 보장한다.
- final : 클래스가 확장되는 것을 완전히 방지한다.
- sealed : 하위 타입의 집합에 대해 포괄성 검사를 활성화한다.
이 수정자들을 사용할 때는 패키지를 게시할 때 주요 버전을 증가시키자. 이러한 수정자들은 모두 호호나성에 영향을 주는 변경을 의미하기 때문이다.
'다트 공식 문서 번역' 카테고리의 다른 글
다트] 다트에서의 동시성 (0) | 2024.08.12 |
---|---|
다트] 클래스 수정자 조합 (0) | 2024.08.12 |
다트] 클래스 수정자 (0) | 2024.08.07 |
다트] 호출 가능한 객체 (0) | 2024.08.07 |
다트] 확장 타입 (0) | 2024.08.05 |