상속은 부모의 구현을 물려받는 것입니다. 그런데 때로는 구현이 아니라 "이런 기능을 반드시 제공해야 한다"는 약속만 정하고 싶을 때가 있습니다. 결제 시스템을 예로 들면, 카드결제와 계좌이체는 동작이 다르지만 둘 다 "결제할 수 있어야 하고, 환불할 수 있어야 한다"는 공통된 약속이 필요합니다. 이 역할을 추상 클래스와 인터페이스가 담당합니다.
abstract class
abstract 키워드를 붙인 클래스는 직접 인스턴스를 만들 수 없습니다. 자식 클래스가 반드시 구현해야 할 메서드를 선언해두는 용도입니다.
// 파일: main.dart
abstract class Animal {
String name;
Animal(this.name);
// 추상 메서드: 바디 없음, 자식이 반드시 구현해야 함
void makeSound();
String get habitat;
// 일반 메서드: 기본 구현 있음
void breathe() {
print('$name이(가) 숨을 쉽니다.');
}
void introduce() {
print('나는 $name입니다. 서식지: $habitat');
makeSound();
}
}
class Dog extends Animal {
Dog(String name) : super(name);
@override
void makeSound() => print('$name: 왈왈!');
@override
String get habitat => '가정';
}
class Eagle extends Animal {
Eagle(String name) : super(name);
@override
void makeSound() => print('$name: 끼이익!');
@override
String get habitat => '산악 지대';
}
void main() {
// var a = Animal('동물'); // 오류: abstract class는 인스턴스 불가
var dog = Dog('바둑이');
var eagle = Eagle('독수리');
dog.introduce();
eagle.introduce();
List<Animal> animals = [Dog('해피'), Eagle('청수리'), Dog('코코')];
for (var a in animals) {
a.makeSound();
}
}
추상 클래스에는 추상 메서드와 일반 메서드를 섞어 쓸 수 있습니다. breathe()처럼 공통 구현은 그대로 물려주고, makeSound()처럼 동물마다 다른 동작은 자식에게 맡깁니다.
implements
Dart에서 특이한 점이 있습니다. 모든 클래스는 암묵적으로 인터페이스를 정의합니다. implements 키워드를 사용하면 어떤 클래스든 인터페이스로 사용할 수 있습니다. 단, 인터페이스로 사용할 때는 해당 클래스의 모든 멤버를 직접 구현해야 합니다.
// 파일: main.dart
abstract class Printable {
void print();
}
abstract class Saveable {
void save(String path);
Future<void> saveAsync(String path);
}
class Document implements Printable, Saveable {
final String content;
final String title;
Document(this.title, this.content);
@override
void print() {
// Dart의 print 함수와 이름이 겹치므로 전체 경로 지정
// ignore: avoid_print
dart_print('=== $title ===\n$content');
}
@override
void save(String path) {
// ignore: avoid_print
dart_print('$title을(를) $path에 저장합니다.');
}
@override
Future<void> saveAsync(String path) async {
await Future.delayed(const Duration(milliseconds: 100));
// ignore: avoid_print
dart_print('$title을(를) $path에 비동기 저장 완료.');
}
}
// 충돌 방지용 별칭
void dart_print(String msg) => print(msg);
void main() async {
var doc = Document('Dart 안내서', 'Dart는 구글이 만든 언어입니다.');
doc.print();
doc.save('/docs/dart_guide.txt');
await doc.saveAsync('/backup/dart_guide.txt');
}
extends vs implements
이 두 키워드의 차이를 명확히 정리합니다.
// 파일: main.dart
class Logger {
void log(String message) {
print('[LOG] $message');
}
void error(String message) {
print('[ERROR] $message');
}
}
// extends: log(), error() 구현을 물려받음
class FileLogger extends Logger {
final String filePath;
FileLogger(this.filePath);
@override
void log(String message) {
super.log(message);
print(' → $filePath에 기록');
}
}
// implements: log(), error()를 직접 구현해야 함
class RemoteLogger implements Logger {
final String endpoint;
RemoteLogger(this.endpoint);
@override
void log(String message) {
print('[REMOTE] $message → $endpoint');
}
@override
void error(String message) {
print('[REMOTE ERROR] $message → $endpoint');
}
}
void main() {
Logger fileLog = FileLogger('/var/log/app.log');
Logger remoteLog = RemoteLogger('https://log.example.com');
fileLog.log('서비스 시작');
remoteLog.log('서비스 시작');
remoteLog.error('연결 실패');
}
| 구분 | extends | implements |
|---|---|---|
| 구현 상속 | 받음 | 받지 않음 |
| 반드시 구현할 것 | abstract 메서드만 | 모든 멤버 |
| 동시 사용 개수 | 1개만 | 여러 개 가능 |
다중 인터페이스
Dart는 단일 상속(extends는 하나)이지만, implements는 여러 개를 동시에 사용할 수 있습니다.
// 파일: main.dart
abstract class Flyable {
double altitude;
Flyable(this.altitude);
void fly();
void land();
}
abstract class Swimmable {
double depth;
Swimmable(this.depth);
void swim();
void surface();
}
abstract class Walkable {
void walk();
void run();
}
// 오리는 날고, 헤엄치고, 걸을 수 있음
class Duck implements Flyable, Swimmable, Walkable {
final String name;
@override
double altitude = 0;
@override
double depth = 0;
Duck(this.name);
@override
void fly() {
altitude = 100;
print('$name이(가) ${altitude}m 상공을 납니다.');
}
@override
void land() {
altitude = 0;
print('$name이(가) 착지합니다.');
}
@override
void swim() {
depth = 0.5;
print('$name이(가) 물 위를 헤엄칩니다.');
}
@override
void surface() {
depth = 0;
print('$name이(가) 수면으로 올라옵니다.');
}
@override
void walk() {
print('$name이(가) 뒤뚱뒤뚱 걷습니다.');
}
@override
void run() {
print('$name이(가) 빠르게 뒤뚱거립니다.');
}
}
void makeFly(Flyable f) => f.fly();
void makeSwim(Swimmable s) => s.swim();
void main() {
var duck = Duck('도널드');
duck.fly();
duck.swim();
duck.walk();
// 각 인터페이스 타입으로도 사용 가능
makeFly(duck);
makeSwim(duck);
}
Duck 하나가 Flyable, Swimmable, Walkable 세 가지 역할을 모두 수행합니다. makeFly 함수는 Flyable 인터페이스만 알고 있지만, Duck을 전달하면 정상 동작합니다.
extends와 implements 혼용
extends와 implements를 함께 쓸 수도 있습니다.
// 파일: main.dart
abstract class Vehicle {
String brand;
Vehicle(this.brand);
void move();
void stop();
}
abstract class Electric {
int batteryLevel;
Electric(this.batteryLevel);
void charge();
int get range;
}
class ElectricCar extends Vehicle implements Electric {
@override
int batteryLevel;
ElectricCar(String brand, this.batteryLevel) : super(brand);
@override
void move() => print('$brand 전기차가 조용히 달립니다.');
@override
void stop() => print('$brand 전기차가 멈춥니다. 회생제동 작동.');
@override
void charge() => print('$brand 충전 중... 현재 $batteryLevel%');
@override
int get range => batteryLevel * 5; // 1%당 5km 가정
}
void main() {
var car = ElectricCar('Ioniq 6', 80);
car.move();
car.stop();
car.charge();
print('주행 가능 거리: ${car.range}km');
}
추상 클래스와 인터페이스는 코드의 유연성을 높입니다. 구현체가 바뀌어도 인터페이스 타입만 맞으면 코드 수정 없이 교체할 수 있습니다. 이것이 객체지향 설계의 핵심 원칙 중 하나입니다.
다음 챕터에서는 Dart만의 독특한 기능인 믹스인(mixin)을 배웁니다. 상속 계층 없이 기능을 조합하는 방법입니다.