상속과 다형성은 객체 지향 프로그래밍의 핵심 개념으로, 코드 재사용성을 높이고 확장 가능성을 제공하는 강력한 도구이다. 이 섹션에서는 Dart에서 상속과 다형성을 어떻게 구현하고 사용하는지에 대해 깊이 있게 다루겠다.

상속(Inheritance)

상속은 하나의 클래스가 다른 클래스의 속성과 메소드를 물려받는 개념이다. Dart에서는 extends 키워드를 사용하여 상속을 구현할 수 있다. 상속은 코드 재사용을 촉진하고, 클래스 간의 관계를 계층적으로 표현할 수 있게 한다.

상속의 기본 문법

상속의 기본적인 구조는 다음과 같다.

class Parent {
  void speak() {
    print('Parent is speaking');
  }
}

class Child extends Parent {
  @override
  void speak() {
    print('Child is speaking');
  }
}

여기서 Child 클래스는 Parent 클래스를 상속받았으며, speak() 메소드를 오버라이딩하여 고유한 동작을 정의하였다. 하지만 부모 클래스인 Parent의 메소드와 속성을 그대로 사용할 수도 있다.

다형성(Polymorphism)

다형성은 동일한 인터페이스 또는 상속 계층 구조를 따르는 객체들이 다른 방식으로 동작할 수 있는 능력을 의미한다. Dart에서는 객체가 여러 형태를 가질 수 있게 하여 유연한 코드를 작성할 수 있다. 다형성은 주로 상속과 인터페이스를 통해 구현된다.

다형성의 예시

class Animal {
  void sound() {
    print('Animal makes a sound');
  }
}

class Dog extends Animal {
  @override
  void sound() {
    print('Dog barks');
  }
}

class Cat extends Animal {
  @override
  void sound() {
    print('Cat meows');
  }
}

void makeSound(Animal animal) {
  animal.sound();
}

void main() {
  var dog = Dog();
  var cat = Cat();

  makeSound(dog);  // Dog barks
  makeSound(cat);  // Cat meows
}

이 코드에서 DogCat은 모두 Animal 클래스를 상속받았으며, sound() 메소드를 각자의 방식으로 오버라이딩하였다. makeSound() 함수는 Animal 타입의 매개변수를 받기 때문에 DogCat 객체를 동일한 방식으로 처리하지만, 각 객체의 메소드가 다르게 실행된다. 이게 바로 다형성의 강력한 특징이다.

상속과 다형성의 수학적 모델링

상속과 다형성을 수학적으로 표현하면, 기본적으로 계층 구조에서 각 클래스가 어떤 역할을 하는지 설명할 수 있다. 예를 들어, 클래스 간의 상속 관계는 트리 구조로 표현된다. 클래스 C_1이 클래스 P_1을 상속받을 경우, 이를 트리로 나타내면 다음과 같다:

classDiagram Parent <|-- Child class Parent { +speak() void } class Child { +speak() void }

여기서 P_1은 부모 클래스, C_1은 자식 클래스를 나타내며, 자식 클래스는 부모 클래스의 속성과 메소드를 상속받는다.

상속의 수학적 관계

상속을 수학적으로 모델링할 때는 자식 클래스가 부모 클래스의 속성과 메소드를 상속받는 구조를 함수적 관계로 정의할 수 있다. 부모 클래스에서 정의된 메소드를 f_P(x)로 표현하고, 자식 클래스에서 이를 오버라이드한 메소드를 f_C(x)로 정의할 수 있다.

자식 클래스에서 부모 클래스의 메소드를 오버라이딩하는 경우, 다음과 같은 함수 관계를 만족한다.

f_C(x) = f_P(x) + \delta f(x)

여기서 \delta f(x)는 자식 클래스에서 추가적으로 정의된 메소드의 차이를 나타낸다. 이 관계는 다형성의 원리를 수학적으로 설명한다.

다형성의 수학적 모델링

다형성은 여러 클래스로부터 파생된 객체들이 동일한 메소드를 호출하더라도 다른 결과를 반환하는 구조를 의미한다. 이를 수학적으로 표현하면, 다양한 함수의 집합에서 특정 객체가 어느 함수에 대응되는지 결정하는 매핑으로 나타낼 수 있다.

예를 들어, 여러 하위 클래스의 메소드를 다음과 같이 정의할 수 있다:

이때, 다형성에 의해 호출된 메소드는 다음과 같은 조건을 만족한다:

f_{Parent}(x) \in \{ f_{C1}(x), f_{C2}(x), \dots \}

이는 상위 클래스에서 정의된 메소드가 여러 하위 클래스의 메소드 중 하나로 대체될 수 있음을 의미한다.

다형성의 동적 바인딩(Dynamic Binding)

다형성은 동적 바인딩(Dynamic Binding)이라는 개념을 통해 구현된다. 동적 바인딩은 컴파일 시간에 결정되지 않고, 실행 시간에 객체의 타입에 따라 해당 메소드가 호출되는 방식이다. Dart는 런타임에 객체의 타입을 결정하여 적절한 메소드를 호출한다.

동적 바인딩의 예시

class Shape {
  void draw() {
    print('Drawing a shape');
  }
}

class Circle extends Shape {
  @override
  void draw() {
    print('Drawing a circle');
  }
}

class Square extends Shape {
  @override
  void draw() {
    print('Drawing a square');
  }
}

void render(Shape shape) {
  shape.draw();
}

void main() {
  var circle = Circle();
  var square = Square();

  render(circle);  // Drawing a circle
  render(square);  // Drawing a square
}

위의 코드에서 Shape 클래스는 기본적인 draw() 메소드를 제공하지만, CircleSquare 클래스는 이 메소드를 각각 자신들의 방식으로 오버라이딩한다. render() 함수는 Shape 타입을 받아서 다형성을 이용해 실행 시간에 적절한 draw() 메소드를 호출한다. 이를 동적 바인딩이라고 한다.

수학적으로 동적 바인딩 설명

수학적으로 동적 바인딩을 표현하면, 특정 부모 클래스의 메소드가 여러 자식 클래스에 의해 동적으로 결정될 수 있는 매핑을 의미한다. 이를 다음과 같이 나타낼 수 있다.

부모 클래스의 메소드를 f_P(x), 자식 클래스의 메소드를 f_{C1}(x), f_{C2}(x), \dots로 표현할 때, 런타임에서 메소드는 다음과 같은 조건을 만족하는 함수로 결정된다.

f(x) = \begin{cases} f_{C1}(x) & \text{if } \text{instance of } C1 \\ f_{C2}(x) & \text{if } \text{instance of } C2 \\ \vdots \\ f_P(x) & \text{otherwise} \end{cases}

여기서 조건에 따라 자식 클래스의 메소드가 호출되며, 부모 클래스의 메소드가 기본적으로 호출될 수 있다. 이 관계는 다형성과 동적 바인딩의 수학적 기초를 나타낸다.

다형성의 이점

코드의 재사용성

다형성은 코드 재사용성을 높인다. 여러 클래스에서 공통 인터페이스나 부모 클래스를 상속받아 구현할 경우, 동일한 함수에서 다양한 객체들을 처리할 수 있다. 이로 인해 코드의 확장성과 유지보수성이 향상된다.

유연한 설계

다형성은 설계의 유연성을 제공한다. 클래스 구조를 계층적으로 설계하고, 필요에 따라 새로운 하위 클래스를 추가하는 방식으로 프로그램을 확장할 수 있다. 새로운 클래스는 기존의 코드를 수정하지 않고도 추가될 수 있으며, 동작은 런타임에 결정된다.

상속과 다형성의 예시를 통한 구체화

상속과 다형성은 구체적인 프로그램 예시를 통해 더욱 명확하게 이해할 수 있다. 상속을 통한 기본 기능의 재사용과 다형성을 활용한 객체 간의 유연한 상호작용을 예시 프로그램으로 설명한다.

예시 프로그램: 동물원 시뮬레이션

class Animal {
  void sound() {
    print('Animal sound');
  }
}

class Lion extends Animal {
  @override
  void sound() {
    print('Lion roars');
  }
}

class Elephant extends Animal {
  @override
  void sound() {
    print('Elephant trumpets');
  }
}

void makeSound(Animal animal) {
  animal.sound();
}

void main() {
  var lion = Lion();
  var elephant = Elephant();

  makeSound(lion);  // Lion roars
  makeSound(elephant);  // Elephant trumpets
}

이 예제에서는 Animal 클래스를 상속받는 LionElephant 클래스가 각각 자신들의 방식으로 sound() 메소드를 구현하였다. makeSound() 함수는 다형성을 활용하여 Animal 타입의 객체를 받아들이다. 그 결과, LionElephant 객체에 따라 각각의 sound() 메소드가 호출된다.

상속과 다형성의 한계

상속과 다형성은 강력한 개념이지만, 남용할 경우 코드가 복잡해지거나 유지보수가 어려워질 수 있다. 상속 계층이 깊어질수록 클래스 간의 의존성이 커지기 때문에 신중하게 사용해야 한다.

다중 상속의 제한

Dart에서는 다중 상속을 지원하지 않는다. 즉, 한 클래스는 오직 하나의 부모 클래스만 상속받을 수 있다. 이는 다중 상속이 복잡성을 증가시키고 메소드 충돌을 일으킬 수 있기 때문에 Dart에서는 이를 방지하고 있다. 대신 mixin을 사용하여 여러 클래스의 기능을 조합할 수 있다.

예시: mixin을 활용한 다중 상속 대체

mixin Flyable {
  void fly() {
    print('Flying');
  }
}

mixin Swimmable {
  void swim() {
    print('Swimming');
  }
}

class Duck with Flyable, Swimmable {
  void quack() {
    print('Quacking');
  }
}

void main() {
  var duck = Duck();
  duck.fly();  // Flying
  duck.swim();  // Swimming
  duck.quack();  // Quacking
}

여기서 Duck 클래스는 FlyableSwimmable이라는 mixin을 사용하여 두 가지 기능을 모두 구현할 수 있다. Dart에서 mixin은 다중 상속의 대안으로 사용되며, 여러 클래스의 메소드를 조합하여 사용 가능한 유연한 방식을 제공한다.

상속과 다형성의 설계 패턴

객체 지향 설계에서는 상속과 다형성을 효과적으로 사용하는 다양한 패턴들이 있다. 그중 가장 대표적인 패턴으로 템플릿 메소드 패턴팩토리 패턴을 들 수 있다. 이 패턴들은 상속과 다형성의 특성을 극대화하여 코드의 재사용성과 유연성을 향상시킨다.

템플릿 메소드 패턴(Template Method Pattern)

템플릿 메소드 패턴은 상위 클래스에서 기본적인 알고리즘의 구조를 정의하고, 하위 클래스에서 세부적인 구현을 제공하는 패턴이다. 이를 통해 상위 클래스에서는 공통적인 로직을 정의하고, 하위 클래스에서는 특수한 동작을 구현할 수 있다.

abstract class Game {
  void start() {
    print('Game started');
  }

  void play();  // Template method

  void end() {
    print('Game ended');
  }

  // Template method
  void run() {
    start();
    play();
    end();
  }
}

class Soccer extends Game {
  @override
  void play() {
    print('Playing soccer');
  }
}

class Basketball extends Game {
  @override
  void play() {
    print('Playing basketball');
  }
}

void main() {
  var soccer = Soccer();
  var basketball = Basketball();

  soccer.run();  // Template method is executed
  basketball.run();  // Template method is executed
}

여기서 Game 클래스는 템플릿 메소드인 run()을 통해 게임의 전반적인 흐름을 제어한다. 각 하위 클래스인 SoccerBasketball은 게임의 구체적인 플레이 방식을 정의하며, run() 메소드는 상위 클래스의 로직을 따르면서도 다형성을 통해 각각의 동작을 수행한다.

팩토리 패턴(Factory Pattern)

팩토리 패턴은 객체 생성 로직을 별도의 메소드나 클래스에서 처리하여 코드의 의존성을 낮추고 확장성을 높이는 패턴이다. 상속과 다형성을 활용하여 팩토리 패턴을 구현할 수 있다.

abstract class Animal {
  void speak();
}

class Dog extends Animal {
  @override
  void speak() {
    print('Woof');
  }
}

class Cat extends Animal {
  @override
  void speak() {
    print('Meow');
  }
}

class AnimalFactory {
  static Animal createAnimal(String type) {
    if (type == 'dog') {
      return Dog();
    } else if (type == 'cat') {
      return Cat();
    } else {
      throw Exception('Unknown animal type');
    }
  }
}

void main() {
  var dog = AnimalFactory.createAnimal('dog');
  var cat = AnimalFactory.createAnimal('cat');

  dog.speak();  // Woof
  cat.speak();  // Meow
}

팩토리 패턴에서는 AnimalFactory 클래스가 객체 생성의 책임을 맡으며, 이로 인해 클라이언트 코드가 구체적인 객체의 타입에 의존하지 않게 된다. 팩토리 메소드는 다형성을 이용하여 다양한 타입의 객체를 유연하게 생성할 수 있다.

상속과 다형성의 모범 사례

상속과 다형성을 효과적으로 사용하기 위해서는 몇 가지 모범 사례를 따르는 것이 좋다. 올바른 설계를 통해 상속의 장점을 최대화하고, 불필요한 복잡성을 피할 수 있다.

상속을 남용하지 않기

상속을 남용하면 클래스 간의 강한 결합이 발생하여, 유지보수 및 확장이 어려워질 수 있다. 상속은 "is-a" 관계를 나타내는 데 적합하며, 클래스 간의 일반적인 관계를 정의하는 데 사용해야 한다.

인터페이스 활용하기

상속보다 인터페이스나 mixin을 사용하는 것이 더 적합한 경우가 많다. 인터페이스는 클래스 간의 결합도를 낮추며, 코드의 유연성을 높일 수 있다.