Dart 클래스·mixin·확장 메서드: Dart 3.11 실행 검증
생성자와 const, extends·implements·with 차이, mixin 실행 순서, 확장 메서드의 정적 디스패치 함정, ==/hashCode, Dart 3 클래스 수식어를 실제 에러와 함께.
시리즈: Dart 강좌 3/6 (중급). Dart 함수와 컬렉션은 다룰 줄 알고, 이제 Flutter 앱이나 패키지의 클래스를 설계해야 하는 분을 위한 글입니다. 다 읽고 나면 extends·implements·with·확장 메서드를 상황에 맞게 고르고, mixin 실행 순서를 예측하고, 올바른 ==/hashCode 를 쓰고, Dart 3 클래스 수식어 에러를 짐작이 아니라 읽어서 고칠 수 있습니다.
Flutter 로 외주나 사내 앱을 만들다 보면 클래스를 딱히 "고르지" 않고 쓰게 됩니다. StatelessWidget 은 상속하는 것, const 는 린터가 노란 줄을 그으면 붙이는 것, SingleTickerProviderStateMixin 은 예제에서 복사해 오는 것. 이 글은 그 방식이 더는 통하지 않는 순간을 위한 글입니다. 중복이 안 걸러지는 Set, 갑자기 "사라지는" 확장 메서드, 패키지를 올렸더니 튀어나오는 can't be implemented outside of its library. 아래 출력과 에러는 전부 실제 실행 결과를 그대로 옮긴 것입니다.
실행 환경: Dart SDK 3.11.5(stable, macos_arm64), Apple Silicon Mac(M1 Pro). 파일 하나를 dart run(JIT)으로 돌리고, 측정은 dart compile exe(AOT)로 한 번 더 했습니다. 분석기 메시지는 dart analyze 출력입니다. 출력 속 경로는 ~/demo/ 로 바꿨습니다. 인용한 Flutter 소스는 Flutter 3.41.9 입니다.
생성자: 초기화 리스트·이름 있는 생성자·factory·const
실무 코드에서 꼭 만나는 생성자 네 가지를 파일 하나에 담았습니다.
class Point {
final int x, y;
const Point(this.x, this.y);
}
class Temperature {
final double celsius;
final double fahrenheit;
// 초기화 리스트: 본문보다 먼저 실행되고 final 필드를 채울 수 있다
Temperature(this.celsius)
: assert(celsius >= -273.15, 'below absolute zero'),
fahrenheit = celsius * 9 / 5 + 32;
Temperature.freezing() : this(0); // 이름 있는 생성자 + 리다이렉트
}
class Logger {
final String name;
static final _cache = <String, Logger>{};
Logger._(this.name) { print(' created Logger($name)'); }
factory Logger(String name) => _cache.putIfAbsent(name, () => Logger._(name));
}
void main() {
const a = Point(1, 2);
const b = Point(1, 2);
final c = Point(1, 2);
final d = Point(1, 2);
print('const a vs const b identical: ${identical(a, b)}');
print('new c vs new d identical: ${identical(c, d)}');
print('a == c: ${a == c}');
print(Temperature(100).fahrenheit);
print(Temperature.freezing().fahrenheit);
final l1 = Logger('net');
final l2 = Logger('net');
print('factory returned same object: ${identical(l1, l2)}');
try {
Temperature(-300);
} catch (e) {
print('${e.runtimeType}: $e');
}
}
$ dart run ctor.dart
const a vs const b identical: true
new c vs new d identical: false
a == c: false
212.0
32.0
created Logger(net)
factory returned same object: true
눈여겨볼 점은 세 가지입니다.
- const 객체는 정규화(canonicalize)된다.
const Point(1, 2)를 두 번 써도 객체는 하나라서identical이true. const 를 빼면 객체가 둘이 되고,Point가==를 override 하지 않았으니a == c도false입니다. - factory 생성자는 이미 있는 객체를 돌려줄 수 있다.
Logger('net')을 두 번 불러도 "created" 는 한 번만 찍혔습니다. 일반(생성형) 생성자는 이렇게 못 합니다. - assert 가 동작하지 않았다.
Temperature(-300)은 아무것도 던지지 않았고 catch 도 아무것도 출력하지 않았습니다.dart run은 assert 를 켜지 않기 때문입니다.dart run --enable-asserts ctor.dart로 돌리자 마지막 줄이_AssertionError: '~/demo/ctor.dart': Failed assertion: line 11 pos 16: 'celsius >= -273.15': below absolute zero로 나왔습니다. Flutter 디버그 빌드는 assert 를 켜고 릴리스 빌드는 끕니다. assert 는 개발 중 점검용이지 입력 검증이 아닙니다.
const 규칙은 컴파일러가 강제합니다. 처음에 자주 보는 메시지는 이 셋입니다.
error - Can't define a const constructor for a class with non-final fields. … - const_constructor_with_non_final_field
error - Arguments of a constant creation must be constant expressions. … - const_with_non_constant_argument
error - Const variables must be initialized with a constant value. … - const_initialized_with_non_constant_value
두 번째와 세 번째는 var n = 3; const p = P(n); 에서 나왔습니다. const 객체는 컴파일 시점에 아는 값으로만 만들 수 있습니다.
실측: const 와 일반 생성의 할당 비용
Flutter 린터(prefer_const_constructors)가 왜 자꾸 const 를 붙이라고 할까요. EdgeInsets 와 Text 를 흉내 낸 작은 클래스로 100만 개짜리 리스트를 const 있이/없이 만들어 비교했습니다.
class Insets {
final double l, t, r, b;
const Insets.all(double v) : l = v, t = v, r = v, b = v;
}
class Label {
final String text;
final Insets padding;
const Label(this.text, {this.padding = const Insets.all(8)});
}
const n = 1000000;
List<Label> buildConst() => List.generate(n, (_) => const Label('Hello', padding: Insets.all(8)));
List<Label> buildNew() => List.generate(n, (_) => Label('Hello', padding: Insets.all(8)));
각각 측정 없이 3번 돌린 뒤 9번 재서 중앙값을 냈습니다. 메모리는 새 프로세스에서 한 번 만들기 전후의 ProcessInfo.currentRss 차이이고, 서로 다른 객체 수는 Set.identity() 로 셌습니다.
$ dart compile exe constbench.dart -o constbench && ./constbench
const Label x 1000000: median 2.73 ms
new Label x 1000000: median 40.51 ms
$ ./constbench rss-const ; ./constbench rss-new
rss-const: RSS +7.7 MiB, distinct objects in list: 1
rss-new: RSS +118.9 MiB, distinct objects in list: 1000000
const 리스트는 같은 객체 하나를 가리키는 참조 100만 개일 뿐이라 7.7MiB 는 거의 리스트 자체 크기입니다. const 가 없으면 요소마다 Label 과 Insets 두 개를 할당합니다. JIT(dart run)에서는 8.10ms 대 40.10ms 였습니다. RSS 는 대략적인 지표지만, 두 번째 실행에서도 0.1MiB 안쪽으로 재현됐습니다.
Flutter 에서는 이득이 하나 더 있습니다. Flutter 3.41.9 의 Element.updateChild 는 자식을 리빌드하기 전에 child.widget == newWidget 을 확인하고, Widget.operator == 는 @nonVirtual 이며 super == other, 즉 동일성 비교입니다. const 서브트리는 build() 때마다 같은 객체이므로 갱신을 건너뛸 수 있고, 매번 Text('Hello') 를 새로 만들면 매번 갱신 경로를 탑니다.
getter·setter, 상속, 추상 클래스
getter 와 setter 는 호출하는 쪽에서 필드처럼 보이므로, 나중에 검증을 넣어도 호출부를 고칠 필요가 없습니다.
class Volume {
int _level = 5;
int get level => _level;
set level(int v) {
if (v < 0 || v > 10) throw RangeError.range(v, 0, 10, 'level');
_level = v;
}
bool get isMuted => _level == 0;
}
void main() {
final v = Volume();
v.level = 0;
print('level=${v.level} muted=${v.isMuted}');
try { v.level = 11; } on RangeError catch (e) { print(e); }
}
level=0 muted=true
RangeError (level): Invalid value: Not in inclusive range 0..10: 11
_level 의 밑줄은 클래스가 아니라 라이브러리(파일) 단위 private 입니다. 자바나 코틀린에서 온 분들이 처음 헷갈리는 부분이고, 뒤에 나올 클래스 수식어에서도 다시 중요해집니다.
Dart 의 모든 클래스는 암묵적인 인터페이스도 하나씩 정의합니다. extends 는 코드를 물려받고, implements 는 타입만 받아서 멤버를 전부 직접 써야 합니다. 하나라도 빠뜨리면 분석기가 막습니다.
class Animal { String get name => 'a'; String describe() => name; }
class Fake implements Animal {}
error - Missing concrete implementations of 'Animal.describe' and 'getter Animal.name'. Try implementing the missing methods, or make the class abstract. - non_abstract_class_inherits_abstract_member
테스트용 페이크에 implements 를 쓰는 이유가 바로 이것입니다. 타입은 같고 동작은 하나도 물려받지 않습니다.
on 이 붙은 mixin 과 실제 실행 순서
mixin 은 여러 클래스 계층에 붙일 수 있는 멤버 묶음입니다. on Animal 을 붙이면 Animal 의 하위 클래스에만 쓸 수 있는 대신 super 를 부를 수 있습니다.
abstract class Animal {
String get name;
String describe() => '$name: Animal.describe';
}
mixin Walker on Animal {
@override
String describe() => 'Walker -> ${super.describe()}';
String walk() => '$name walks';
}
mixin Swimmer on Animal {
@override
String describe() => 'Swimmer -> ${super.describe()}';
}
class Duck extends Animal with Walker, Swimmer {
@override
final String name = 'duck';
@override
String describe() => 'Duck -> ${super.describe()}';
}
class Penguin extends Animal with Swimmer, Walker {
@override
final String name = 'penguin';
}
class FakeAnimal implements Animal {
@override
String get name => 'fake';
@override
String describe() => 'FakeAnimal wrote everything itself';
}
void main() {
print(Duck().describe());
print(Penguin().describe());
print(FakeAnimal().describe());
print(Duck() is Walker && Duck() is Swimmer && Duck() is Animal);
}
Duck -> Swimmer -> Walker -> duck: Animal.describe
Walker -> Swimmer -> penguin: Animal.describe
FakeAnimal wrote everything itself
true
with Walker, Swimmer 는 Animal → Animal + Walker → Animal + Walker + Swimmer → Duck 으로 이어지는 사슬을 만듭니다. 가장 오른쪽 mixin 이 클래스에 가장 가까워서 먼저 실행되고, 그 super 는 왼쪽 옆 mixin 입니다. Penguin 처럼 순서를 바꾸면 출력도 바뀝니다.
on Animal mixin 을 Animal 이 아닌 클래스에 붙이면 이런 에러가 납니다.
error - 'Walker' can't be mixed onto 'Object' because 'Object' doesn't implement 'Animal'. Try extending the class 'Walker'. - mixin_application_not_implemented_interface
Flutter 의 SingleTickerProviderStateMixin 을 State 하위 클래스에만 붙일 수 있는 것도 on State 로 선언돼 있기 때문입니다.
흔한 실수: 확장 메서드는 정적으로 선택된다
확장 메서드(extension)를 쓰면 int·String 처럼 내 것이 아닌 타입에도 메서드를 붙일 수 있습니다. 편리하지만 함정이 하나 있습니다.
extension on Object { String get kind => 'Object'; }
extension on String { String get kind => 'String'; }
extension IntPrice on int {
String get yen => '¥${toString().replaceAllMapped(RegExp(r'\B(?=(\d{3})+$)'), (_) => ',')}';
}
class Box { String get kind => 'Box member'; }
extension on Box { String get kind => 'Box extension'; }
void main() {
String s = 'hi';
Object o = s; // 같은 객체, 다른 정적 타입
dynamic d = s;
print('s.kind = ${s.kind}');
print('o.kind = ${o.kind}');
print('Box().kind = ${Box().kind}');
print(1234567.yen);
try { print(d.kind); } catch (e) { print('d.kind -> ${e.runtimeType}'); }
}
s.kind = String
o.kind = Object
Box().kind = Box member
¥1,234,567
d.kind -> NoSuchMethodError
(원화로 바꾸려면 '¥' 를 '₩' 로만 바꾸면 됩니다.)
실제로 일어나는 일: 컴파일러는 변수의 정적 타입을 보고 확장을 고릅니다. o 안에는 실행 시점에 String 이 들어 있지만 선언 타입이 Object 라서 Object 용 확장이 실행됩니다. dynamic 으로는 확장을 아예 찾지 않아 실행 중에 실패합니다. 클래스의 진짜 멤버는 언제나 확장보다 우선하며, Box 확장에는 분석기가 warning - The declaration 'kind' isn't referenced. - unused_element 를 띄웠습니다.
고치는 법: 실행 시점 타입에 따라 동작이 달라져야 한다면 확장이 아니라 진짜 메서드(또는 인터페이스)로 만듭니다. 확장은 가격 포맷처럼 하위 타입에 따라 의미가 바뀌지 않는 도우미에 씁니다. Flutter 에서는 jsonDecode 결과가 dynamic 이므로, 값에 제대로 타입을 붙인 다음에 확장을 부르세요.
흔한 실수: == 만 override 하고 hashCode 를 빠뜨림 (Set 중복 버그)
class TagA { final String id; TagA(this.id); } // override 없음
class TagB {
final String id;
TagB(this.id);
@override
bool operator ==(Object other) => other is TagB && other.id == id;
// hashCode 는 override 하지 않음
}
class TagC {
final String id;
const TagC(this.id);
@override
bool operator ==(Object other) => other is TagC && other.id == id;
@override
int get hashCode => id.hashCode;
}
class MutableTag {
String id;
MutableTag(this.id);
@override
bool operator ==(Object other) => other is MutableTag && other.id == id;
@override
int get hashCode => id.hashCode;
}
void main() {
print('A: ${{TagA('x'), TagA('x')}.length} ==: ${TagA('x') == TagA('x')}');
print('B: ${{TagB('x'), TagB('x')}.length} ==: ${TagB('x') == TagB('x')}');
print('B contains: ${{TagB('x')}.contains(TagB('x'))}');
print('C: ${{TagC('x'), TagC('x')}.length} ==: ${TagC('x') == TagC('x')}');
final t = MutableTag('a');
final set = {t};
t.id = 'b';
print('mutated key: contains(t)=${set.contains(t)} length=${set.length}');
}
A: 2 ==: false
B: 2 ==: true
B contains: false
C: 1 ==: true
mutated key: contains(t)=false length=1
실제로 일어나는 일: TagB 끼리는 == 로 같은데도 {TagB('x'), TagB('x')} 의 길이는 2 입니다. Set 과 Map 은 먼저 hashCode 로 정해지는 버킷을 찾습니다. 기본 hashCode 는 객체마다 다르니 두 번째 태그는 다른 버킷에 들어가고 비교조차 되지 않습니다. contains 가 실패하는 것도 같은 이유입니다. 마지막 줄은 더 교묘합니다. 넣은 뒤에 id 를 바꾼 키는 예전 버킷에 그대로 있어서, Set 이 자기가 가진 객체를 못 찾습니다.
옵션 없이 돌린 dart run 과 dart analyze 는 이 파일에 아무 경고도 내지 않았습니다. 린트는 있지만 켜야 동작합니다. hash_and_equals(package:lints core 에 포함, dart create·flutter create 프로젝트에서는 recommended/flutter_lints 로 켜짐)를 켜면 이렇게 나옵니다.
info - Missing a corresponding override of 'hashCode'. Try overriding 'hashCode' or removing '=='. - hash_and_equals
고치는 법: 같은 final 필드로 둘 다 override 하고, 키는 불변으로 둡니다. 값 같은 데이터라면 4강의 레코드를 쓰면 == 와 hashCode 가 처음부터 올바르게 따라옵니다.
Dart 3 클래스 수식어: 실제 컴파일 에러
Dart 3.0 에서 base·interface·final·sealed·mixin class 가 추가됐습니다. 이 수식어들은 라이브러리 바깥 코드에 무엇을 허락할지 정합니다. lib/api.dart 에 다음을 선언했습니다.
base class Repo { void save() {} }
interface class Codec { String encode(String s) => s; }
final class Token { final String v; Token(this.v); }
sealed class Shape {}
class Circle extends Shape {}
abstract interface class Clock { DateTime now(); }
mixin class Loggable { void log(String m) => print(m); }
그리고 main.dart 에서 규칙을 전부 어겨 봤습니다.
import 'lib/api.dart';
class MyRepo implements Repo { @override void save() {} } // 3행
class MyRepo2 extends Repo {} // 4행
class MyCodec extends Codec {} // 5행
class FakeCodec implements Codec { @override String encode(String s) => s; }
class MyToken extends Token { MyToken() : super('x'); } // 7행
class FakeToken implements Token { @override String get v => ''; } // 8행
class Square extends Shape {} // 9행
class SystemClock implements Clock { @override DateTime now() => DateTime.now(); }
void main() {
Codec();
Shape(); // 14행
Clock();
Loggable().log('mixin class used as a class');
}
dart analyze main.dart 결과는 에러 9개입니다(4행·15행의 같은 종류는 생략).
error - main.dart:3:7 - The type 'MyRepo' must be 'base', 'final' or 'sealed' because the supertype 'Repo' is 'base'. - subtype_of_base_or_final_is_not_base_final_or_sealed
error - main.dart:3:25 - The class 'Repo' can't be implemented outside of its library because it's a base class. - invalid_use_of_type_outside_library
error - main.dart:5:23 - The class 'Codec' can't be extended outside of its library because it's an interface class. - invalid_use_of_type_outside_library
error - main.dart:7:23 - The class 'Token' can't be extended outside of its library because it's a final class. - invalid_use_of_type_outside_library
error - main.dart:8:28 - The class 'Token' can't be implemented outside of its library because it's a final class. - invalid_use_of_type_outside_library
error - main.dart:9:22 - The class 'Shape' can't be extended, implemented, or mixed in outside of its library because it's a sealed class. - invalid_use_of_type_outside_library
error - main.dart:14:3 - Abstract classes can't be instantiated. Try creating an instance of a concrete subtype. - instantiate_abstract_class
implements Codec·implements Clock·Codec()·Loggable() 은 통과했습니다. 실무에서 중요한 건 두 가지입니다.
- 같은 라이브러리 안에서는 아무 제한이 없다.
Circle extends Shape는 같은 파일이라 괜찮습니다. 그래서 이 에러는 보통 패키지를 올린 뒤에야 처음 보게 됩니다. - 일반 클래스는 더 이상 mixin 이 아니다.
class Plain {} class X with Plain {}은error - The class 'Plain' can't be used as a mixin because it's neither a mixin class nor a mixin. - class_used_as_mixin입니다. 같은 파일 첫 줄에// @dart=2.19를 넣으면 실행되고hi가 찍혔습니다. 일반 클래스를 mixin 하던 오래된 패키지는 SDK 제약을 Dart 3 로 올릴 때 정확히 여기서 깨집니다. 업그레이드 전반은 Flutter 앱 16개 이전 기록에 정리해 두었습니다.
수식어가 Dart 3.0 기능이라는 것도 확인했습니다. // @dart=2.19 를 붙이면 분석기가 This requires the 'class-modifiers' language feature to be enabled … - experiment_not_enabled(sealed 는 'sealed-class')를 냅니다.
결정표
| 하고 싶은 것 | 쓸 것 | 주의 |
|---|---|---|
| 코드를 재사용하고 is-a 하위 타입이 되기 | extends |
상위 클래스는 하나만 |
| 타입만 필요하고 구현은 직접(페이크, 어댑터) | implements |
멤버를 전부 작성 |
| 관계없는 계층끼리 멤버 공유 | mixin + with |
가장 오른쪽 mixin 이 먼저 |
기반 클래스의 super 가 필요한 mixin |
mixin M on Base |
Base 하위 클래스 전용 |
int·String·남의 패키지 타입에 도우미 추가 |
extension | 정적 디스패치, 다형성용 아님 |
| 키마다 하나, 캐시된 객체 반환 | factory 생성자 |
하위 타입도 반환 가능 |
| 공유 가능한 불변 값(위젯) | const 생성자 |
필드 전부 final, 정규화됨 |
| 구현은 허용, 상속은 금지 | interface class |
라이브러리 밖에서만 적용 |
| 상속은 허용, 구현은 금지 | base class |
하위 클래스도 base/final/sealed |
| 빠짐없는 switch 용 닫힌 하위 타입 집합 | sealed class |
4강 |
| 라이브러리 밖 하위 타입 금지 | final class |
패키지 작성자에게 가장 안전 |
한계와 버전 메모
- 모두 Dart 3.11.5 에서 실행했습니다. 클래스 수식어·
mixin class·sealed가 3.0 기능이라는 점은// @dart=2.19로 확인했고, 예전 SDK 를 따로 설치하지는 않았습니다. - const 측정은 할당 마이크로벤치마크이지 Flutter 프레임 시간이 아닙니다. 실제 효과는 트리의 얼마만큼을 const 로 만들 수 있느냐에 달려 있습니다. RSS 에는 GC 힙 여유분도 섞이므로 MiB 값은 상대 비교로만 보세요.
- 이번 강좌에서는 Flutter 앱을 돌리지 않았습니다.
updateChild동작은 Flutter 3.41.9 프레임워크 소스에서 인용했습니다. analysis_options.yaml없이 돌린dart analyze는hash_and_equals를 켜지 않습니다.dart create/flutter create프로젝트는package:lints/flutter_lints로 켜집니다.
연습 문제
문제 1. 출력을 먼저 예상한 뒤 실행해 보세요.
abstract class Base { String hi() => 'Base'; }
mixin A on Base { @override String hi() => 'A>${super.hi()}'; }
mixin B on Base { @override String hi() => 'B>${super.hi()}'; }
mixin C on Base { @override String hi() => 'C>${super.hi()}'; }
class X extends Base with A, C, B {}
void main() => print(X().hi());
문제 2. final seen = <Email>{}; 에 Email('a@example.com') 을 두 번 넣으면 2개가 됩니다. Email 에는 final String address 필드 하나와 == override 만 있습니다. 1개가 되도록 고치고, 'A@example.com' 도 같은 주소로 취급하게 해 보세요.
정답
1. B>C>A>Base. 가장 오른쪽 mixin(B)이 먼저 실행되고, super 를 부를 때마다 한 칸씩 왼쪽으로 갑니다.
2. == 에 쓴 것과 같은 정규화된 값으로 hashCode 를 만듭니다.
class Email {
final String address;
Email(String a) : address = a.toLowerCase();
@override
bool operator ==(Object other) => other is Email && other.address == address;
@override
int get hashCode => address.hashCode;
}
초기화 리스트에서 정규화해 두면 == 와 hashCode 가 저절로 어긋나지 않습니다. 세 번 넣어서 seen.length 가 1 인 것을 확인했습니다. (address: a.toLowerCase()) 같은 레코드를 써도 됩니다.
관련 글: 자바 클래스·레코드·인터페이스 · Swift 구조체·클래스·프로토콜 · PHP 객체지향·인터페이스·enum
이전: 2강: 함수와 컬렉션 · 다음: 4강: 레코드·패턴·sealed 클래스
자주 묻는 질문
const 생성자를 쓰면 Flutter 위젯이 정말 가벼워지나요?
측정해 보니 차이가 분명했습니다. 작은 객체 100만 개짜리 리스트를 만들 때 const 쪽은 2.73ms, RSS 증가 7.7MiB(모든 요소가 같은 인스턴스 하나), const 없이는 40.51ms, 118.9MiB였습니다(AOT, Apple Silicon Mac M1 Pro). 게다가 Flutter 는 같은 객체인 위젯의 리빌드를 건너뜁니다.
여러 mixin 이 같은 메서드를 override 하면 어떤 순서로 실행되나요?
오른쪽에서 왼쪽입니다. class Duck extends Animal with Walker, Swimmer 에서 describe() 는 Duck → Swimmer → Walker → Animal 순서로 실행됐습니다. with 뒤의 순서를 바꾸면 출력도 바뀝니다.
변수 타입을 Object 나 dynamic 으로 두면 확장 메서드가 안 먹히는 이유는?
확장 메서드는 실행 시점의 타입이 아니라 컴파일 시점의 정적 타입으로 고르기 때문입니다. 같은 String 이라도 Object 타입 변수로 부르면 Object 용 확장이 호출되고, dynamic 으로 부르면 확장을 아예 찾지 않아 NoSuchMethodError 가 납니다.