iBetter Books
수정

코드를 작성하다 보면 비슷하지만 조금씩 다른 클래스가 반복됩니다. "직원"과 "관리자"는 이름, 나이, 출근 방법이 같지만, 관리자는 부하직원 목록과 팀 예산 관리 기능이 추가됩니다. 이럴 때 상속(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에서
}

HumanMammal을 상속하고, MammalAnimal을 상속하고, AnimalLivingThing을 상속합니다. Human 인스턴스는 모든 조상의 기능을 사용할 수 있습니다.

다음 챕터에서는 상속과는 다른 방식으로 관계를 정의하는 추상 클래스와 인터페이스를 배웁니다. "무엇을 해야 하는가"를 강제하는 약속의 세계로 들어갑니다.