코드를 작성하다 보면 비슷하지만 조금씩 다른 클래스가 반복됩니다. "직원"과 "관리자"는 이름, 나이, 출근 방법이 같지만, 관리자는 부하직원 목록과 팀 예산 관리 기능이 추가됩니다. 이럴 때 상속(inheritance)을 씁니다. 기존 클래스(부모)의 코드를 물려받아서 새 클래스(자식)를 만드는 개념입니다.
extends 키워드
// 파일: main.dart
class Animal {
String name;
int age;
Animal(this.name, this.age);
void breathe() {
print('$name이(가) 숨을 쉽니다.');
}
void eat(String food) {
print('$name이(가) $food을(를) 먹습니다.');
}
@override
String toString() => '$name ($age살)';
}
class Dog extends Animal {
String breed;
Dog(String name, int age, this.breed) : super(name, age);
void bark() {
print('$name이(가) 짖습니다. 왈왈!');
}
}
class Cat extends Animal {
bool isIndoor;
Cat(String name, int age, {this.isIndoor = true}) : super(name, age);
void purr() {
print('$name이(가) 그루밍합니다. 그르릉...');
}
}
void main() {
var dog = Dog('바둑이', 3, '진돗개');
dog.breathe(); // Animal에서 상속
dog.eat('사료'); // Animal에서 상속
dog.bark(); // Dog만의 메서드
var cat = Cat('나비', 5);
cat.breathe(); // 상속
cat.purr(); // Cat만의 메서드
print(dog); // 바둑이 (3살)
print(cat); // 나비 (5살)
}
extends Animal은 "Animal의 모든 것을 물려받는다"는 선언입니다. super(name, age)는 부모 클래스의 생성자를 호출합니다. 부모 생성자 호출은 자식 생성자의 초기화 리스트 위치에 씁니다.
super 호출
super로 부모의 필드와 메서드에도 접근할 수 있습니다.
// 파일: main.dart
class Vehicle {
String brand;
int year;
Vehicle(this.brand, this.year);
String get info => '$brand ($year년)';
void start() {
print('$brand 시동을 겁니다.');
}
}
class ElectricVehicle extends Vehicle {
int batteryCapacity; // kWh
ElectricVehicle(String brand, int year, this.batteryCapacity)
: super(brand, year);
@override
void start() {
super.start(); // 부모 메서드 먼저 실행
print('전기 모터가 조용히 작동합니다.');
}
@override
String get info => '${super.info} — 배터리 ${batteryCapacity}kWh';
}
void main() {
var tesla = ElectricVehicle('Tesla Model 3', 2024, 75);
tesla.start();
print(tesla.info);
}
출력 결과입니다.
Tesla Model 3 시동을 겁니다.
전기 모터가 조용히 작동합니다.
Tesla Model 3 (2024년) — 배터리 75kWh
super.start()로 부모 메서드를 먼저 실행한 뒤 자식의 동작을 추가했습니다. super.info는 부모의 getter를 호출합니다.
메서드 오버라이드 (@override)
자식 클래스에서 부모의 메서드를 같은 이름으로 다시 정의하는 것을 오버라이드(override)라고 합니다. @override 어노테이션은 "나는 의도적으로 부모 메서드를 재정의하고 있다"는 표시입니다. 필수는 아니지만, 실수로 부모 메서드와 이름이 겹치는 경우를 방지해주므로 항상 붙이는 것을 권장합니다.
// 파일: main.dart
class Shape {
String color;
Shape(this.color);
double area() => 0;
double perimeter() => 0;
void describe() {
print('$color 도형, 넓이: ${area().toStringAsFixed(2)}, '
'둘레: ${perimeter().toStringAsFixed(2)}');
}
}
class Circle extends Shape {
double radius;
Circle(String color, this.radius) : super(color);
@override
double area() => 3.14159 * radius * radius;
@override
double perimeter() => 2 * 3.14159 * radius;
}
class Rectangle extends Shape {
double width, height;
Rectangle(String color, this.width, this.height) : super(color);
@override
double area() => width * height;
@override
double perimeter() => 2 * (width + height);
}
void main() {
List<Shape> shapes = [
Circle('빨강', 5),
Rectangle('파랑', 4, 6),
Circle('초록', 3),
];
for (var shape in shapes) {
shape.describe();
}
}
출력 결과입니다.
빨강 도형, 넓이: 78.54, 둘레: 31.42
파랑 도형, 넓이: 24.00, 둘레: 20.00
초록 도형, 넓이: 28.27, 둘레: 18.85
List<Shape>에 Circle과 Rectangle을 함께 담고, shape.describe()를 호출하면 각 클래스의 오버라이드된 메서드가 실행됩니다. 이것이 다형성(polymorphism)입니다. 같은 타입으로 다루지만, 실제 동작은 각 클래스에 맞게 실행됩니다.
상속과 생성자의 관계
자식 클래스를 만들 때 부모 생성자를 반드시 호출해야 합니다. 부모가 기본 생성자(매개변수 없음)를 가지고 있다면 자동으로 호출되지만, 매개변수가 있는 생성자라면 직접 super(...)를 써야 합니다.
// 파일: main.dart
class Employee {
String name;
String department;
double salary;
Employee(this.name, this.department, this.salary);
void work() {
print('$name이(가) $department에서 일합니다.');
}
@override
String toString() => '$name ($department, $salary만원)';
}
class Manager extends Employee {
List<String> teamMembers;
Manager(String name, String department, double salary, this.teamMembers)
: super(name, department, salary);
// 이름 있는 생성자도 super를 호출해야 함
Manager.fromEmployee(Employee emp, List<String> members)
: teamMembers = members,
super(emp.name, emp.department, emp.salary);
@override
void work() {
super.work();
print('팀원 ${teamMembers.length}명을 관리합니다: ${teamMembers.join(', ')}');
}
}
void main() {
var emp = Employee('김직원', '개발팀', 350);
emp.work();
print('---');
var manager = Manager('이팀장', '개발팀', 500, ['김직원', '박직원', '최직원']);
manager.work();
print('---');
// fromEmployee 생성자
var promoted = Manager.fromEmployee(emp, ['신입1', '신입2']);
promoted.work();
}
Object 클래스와 기본 오버라이드
Dart의 모든 클래스는 Object를 상속합니다. extends를 쓰지 않아도 암묵적으로 Object의 자식입니다. Object에는 유용한 메서드들이 있고, 필요할 때 오버라이드합니다.
// 파일: main.dart
class Student {
final String name;
final int studentId;
final double gpa;
const Student(this.name, this.studentId, this.gpa);
// == 연산자 오버라이드: 학번이 같으면 같은 학생
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Student && other.studentId == studentId;
}
// == 을 오버라이드하면 hashCode도 오버라이드해야 함
@override
int get hashCode => studentId.hashCode;
// 읽기 좋은 문자열 표현
@override
String toString() => 'Student($name, $studentId, GPA: $gpa)';
}
void main() {
var s1 = Student('홍길동', 20240001, 3.8);
var s2 = Student('홍길동', 20240001, 3.8); // 같은 학번
var s3 = Student('김철수', 20240002, 4.0); // 다른 학번
print(s1 == s2); // true (학번이 같음)
print(s1 == s3); // false
// Set에서도 중복 제거가 제대로 됨
var studentSet = {s1, s2, s3};
print(studentSet.length); // 2 (s1과 s2는 같은 것으로 취급)
print(s1); // Student(홍길동, 20240001, GPA: 3.8)
}
==을 오버라이드할 때 hashCode도 반드시 함께 오버라이드해야 합니다. Set과 Map은 hashCode를 먼저 보고 ==를 확인하기 때문에, hashCode가 다르면 ==이 true를 반환해도 같은 원소로 인식하지 않습니다.
상속 체인
상속은 여러 단계로 이어질 수 있습니다.
// 파일: main.dart
class LivingThing {
bool alive = true;
void grow() => print('성장합니다.');
}
class Animal extends LivingThing {
String name;
Animal(this.name);
void breathe() => print('$name이(가) 호흡합니다.');
}
class Mammal extends Animal {
Mammal(String name) : super(name);
void nurture() => print('$name이(가) 새끼를 돌봅니다.');
}
class Human extends Mammal {
String language;
Human(String name, this.language) : super(name);
void speak() => print('$name이(가) $language로 말합니다.');
}
void main() {
var human = Human('홍길동', '한국어');
human.grow(); // LivingThing에서
human.breathe(); // Animal에서
human.nurture(); // Mammal에서
human.speak(); // Human에서
print(human.alive); // LivingThing에서
}
Human은 Mammal을 상속하고, Mammal은 Animal을 상속하고, Animal은 LivingThing을 상속합니다. Human 인스턴스는 모든 조상의 기능을 사용할 수 있습니다.
다음 챕터에서는 상속과는 다른 방식으로 관계를 정의하는 추상 클래스와 인터페이스를 배웁니다. "무엇을 해야 하는가"를 강제하는 약속의 세계로 들어갑니다.