본문 바로가기
스터디/오브젝트

12장 - 다형성

by 검은도자기 2023. 9. 20.

개요

이번 장에서는 상속의 관점에서 다형적인 타입 계층을 구현하는 방법과 올바른 타입 계층을 구성하기 위해 고려해야 하는 원칙에 관해 소개합니다.

 

 

다형성(Polymorphism)이란?

  • 다형성이라는 단어는 그리스어에서 많은을 의미하는 poly와 형태를 의미하는 morph의 합성어로 많은 형태를 가질 수 있는 능력을 의미하며, 컴퓨터 과학에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의합니다.
  • 위 두 의미를 정리하자면 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이라고 할 수 있습니다.

 

 

다형성 분류

  • 다형성은 크게 유니버설 다형성과 임시(Ad Hoc) 다형성으로 분류할 수 있습니다.
  • 유니버설 다형성은 다시 매개변수 다형성과 포함 다형성으로 분류할 수 있고, 임시 다형성은 오버로딩 다형성과 강제 다형성으로 분류할 수 있습니다.

 

오버로딩 다형성 (Overloading Polymorphism)

public class MathUtil {
    public int product(int x) {
        return x * x;
    }

    public int product(int x, int y) {
        return x * y;
    }
}

public class Main {
    public static void main(String[] args) {
        MathUtil math = new MathUtil();
        System.out.println(math.product(5));    // 25
        System.out.println(math.product(5, 3)); // 15
    }
}
  • 하나의 클래스 안에 다른 매개변수 목록을 가진 동일한 이름의 메서드가 존재하는 경우
  • 메서드 오버로딩을 사용하면 유사한 작업을 수행하는 메서드의 이름을 통일할 수 있기 때문에 기억해야 하는 이름의 수를 극적으로 줄일 수 있습니다.

 

강제 다형성 (Coercion Polymorphism)

public class CoercionExample {
    public static void main(String[] args) {
        // 자동 타입 변환 예제
        int intValue = 5;
        double doubleValue = intValue; // int가 자동으로 double로 변환됩니다.
        System.out.println(doubleValue); // 출력: 5.0

        // 사용자 정의 타입 변환 예제
        Person person = new Person("John", 25);
        System.out.println(person); // Person의 toString() 메서드가 호출됩니다.
    }
}

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // toString() 메서드 오버라이드
    @Override
    public String toString() {
        return "Person[name=" + name + ", age=" + age + "]";
    }
}
  • 언어가 지원하는 자동 타입 변환 또는 사용자가 구현한 타입 변환을 통해 다양한 데이터 유형을 동일한 연산에서 사용할 수 있는 방식

 

매개변수 다형성 (Parametric Polymorphism)

public class ParametricPolymorphismExample {
    public static void main(String[] args) {
        Box<Integer> intBox = new Box<>(10);
        Box<String> strBox = new Box<>("Hello");

        System.out.println(intBox.getContent()); // 출력: 10
        System.out.println(strBox.getContent()); // 출력: Hello
    }
}

class Box<T> {
    private T content;

    public Box(T content) {
        this.content = content;
    }

    public T getContent() {
        return content;
    }

    public void setContent(T content) {
        this.content = content;
    }
}
  • 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식
  • 제네릭 프로그래밍 또는 제네릭 타입에 관련된 다형성입니다

 

포함 다형성 (Inclusion Polymorphism)

abstract class Bird {
    abstract public String sound();
}

class Sparrow extends Bird {
    public String sound() {
        return "chirp";
    }
}

class Crow extends Bird {
    public String sound() {
        return "caw";
    }
}

public class Main {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        Bird crow = new Crow();

        System.out.println(sparrow.sound());  // chirp
        System.out.println(crow.sound());     // caw
    }
}
  • 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 경우
  • 객체지향에서 가장 널리 알려진 형태의 다형성이기에 특별한 언급 없이 다형성이라고 할 때는 포함 다형성을 의미하는 것이 일반적이며, 포함 다형성을 구현하는 가장 일반적인 방법은 상속을 사용하는 것입니다.

 

 

상속의 양면성

  • 객체지향의 근간을 이루는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것입니다. 따라서 객체지향을 작성하기 위해서는 항상 데이터와 행동이라는 두 가지 관점을 함께 고려해야 합니다. 

 

데이터 관점의 상속

// 기본 클래스
class Car {
    String brand;
    int topSpeed;

    Car(String brand, int topSpeed) {
        this.brand = brand;
        this.topSpeed = topSpeed;
    }

    void displayInfo() {
        System.out.println("브랜드: " + brand + ", 최고속도: " + topSpeed + "km/h");
    }
}

// Car 클래스를 상속받는 ElectricCar 클래스
class ElectricCar extends Car {
    // 데이터 관점에서 새로운 속성 추가
    int batteryLife;

    ElectricCar(String brand, int topSpeed, int batteryLife) {
        super(brand, topSpeed);
        this.batteryLife = batteryLife;
    }

    void displayBatteryLife() {
        System.out.println("배터리 수명: " + batteryLife + "시간");
    }
}

public class DataInheritanceExample {
    public static void main(String[] args) {
        ElectricCar tesla = new ElectricCar("Tesla", 250, 18);
        tesla.displayInfo();
        tesla.displayBatteryLife();
    }
}
  • 데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있습니다.
  • 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 되는 것입니다.

 

행동 관점의 상속

// 기본 클래스
class Car {
    void start() {
        System.out.println("자동차가 시동을 켭니다.");
    }

    void stop() {
        System.out.println("자동차가 멈춥니다.");
    }
}

// Car 클래스를 상속받는 ElectricCar 클래스
class ElectricCar extends Car {
    // 행동 관점에서 기존 메서드 오버라이딩 (재정의)
    @Override
    void start() {
        System.out.println("전기 자동차가 전원을 켭니다.");
    }

    // 행동 관점에서 새로운 행동 추가
    void charge() {
        System.out.println("전기 자동차가 충전됩니다.");
    }
}

public class BehaviorInheritanceExample {
    public static void main(String[] args) {
        ElectricCar tesla = new ElectricCar();
        tesla.start();
        tesla.stop();
        tesla.charge();
    }
}
  • 행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미합니다.
  • 부모 클래스에 정의된 어떤 메서드가 자식 클래스에 포함될지는 언어의 종류와 각 언어가 정의하는 접근 제어자의 의미에 따라 다르지만 공통적으로 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함됩니다.
  • 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있습니다.

 

 

어떻게 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있는 것일까?

  • 그 이유는 런타임에서 시스템이 자식 클래스에 해당 메서드가 없으면 부모 클래스에서 해당 메서드를 찾기 때문입니다.
  • 행동 관점에서 상속과 다형성의 기본적인 개념을 이해하기 위해서는 상속 관계로 연결된 클래스 사이의 메서드 탐색 과정을 이해하는 것이 가장 중요합니다.
  • 따라서 메서드를 탐색하는 과정을 자세히 이해하기 위해 상속을 구성하는 개념들을 살펴보겠습니다.

 

업캐스팅과 동적 바인딩

public class CastingExample {
    public static void main(String[] args) {
        // 업캐스팅: 자식 클래스 객체를 부모 클래스 타입으로 변환
        Animal myDog = new Dog();
        
        // 동적바인딩: 런타임에 객체의 실제 타입을 기반으로 메서드를 호출
        myDog.sound();  // 출력: Woof!

        // 다운캐스팅: 부모 클래스 타입을 자식 클래스 타입으로 변환
        // 주의: 업캐스팅된 객체만 다운캐스팅 가능
        Dog downCastedDog = (Dog) myDog;
        downCastedDog.dogSpecificBehavior();  // 출력: The dog is wagging its tail
    }
}

class Animal {
    public void sound() {
        System.out.println("Some sound");
    }
}

class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Woof!");
    }

    public void dogSpecificBehavior() {
        System.out.println("The dog is wagging its tail");
    }
}

 

업캐스팅 (Upcasting)

  • 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것
  • 위 예제에서는 Dog 객체를 Animal 타입의 참조 변수에 할당했습니다.
  • 업캐스팅은 서로 다른 클래스의 인스턴스를 동일한 타입에 할당하는 것을 가능하게 해 줍니다.
  • 따라서 부모 클래스에 대해 작성된 코드를 전혀 수정하지 않고도 자식 클래스에 적용할 수 있습니다.

 

다운캐스팅 (Downcasting)

  • 부모 클래스 타입을 자식 클래스 타입으로 변환하는 것
  • 위 예제에서는 Dog 타입의 메서드 dogSpecificBehavior()를 호출하기 위해 다운캐스팅을 사용했습니다.

 

동적바인딩 (Dynamic Binding)

  • 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩 또는 지연 바인딩이라고 부릅니다.
  • 위 예제에서는 myDog.sound();를 호출하면, JVM은 런타임에 myDog의 실제 타입 (Dog)을 확인하고 Dog 클래스의 sound() 메서드를 호출합니다.
  • 객체지향 언어가 제공하는 업캐스팅과 동적 바인딩을 이용하면 부모 클래스 참조에 대한 메시지 전송을 자식 클래스에 대한 메서드 호출로 변환할 수 있습니다.
  • 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정됩니다.

 

 

오버라이딩과 오버로딩

이전 블로그글 참고

https://com789.tistory.com/16

 

 

self

public class Person {
    private String name;  // 인스턴스 변수 'name'

    public Person(String name) {
        this.name = name;  // 'this.name'은 인스턴스 변수를 가리키며, 'name'은 생성자의 매개변수를 가리킵니다.
    }

    public void sayHello() {
        System.out.println("Hello, my name is " + this.name + "!");
        // 여기서 'this.name'은 인스턴스 변수 'name'을 참조합니다.
    }

    public static void main(String[] args) {
        Person person = new Person("Alice");
        person.sayHello();  // 출력: Hello, my name is Alice!
    }
}
  • self는 객체의 인스턴스 자신을 참조하는 변수입니다.
  • 다른 객체 지향 언어에서는 this라는 키워드로 유사한 기능을 가진 경우가 많습니다.
  • 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀌는데 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조입니다.
  • self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있으며, self 참조는 현재 클래스의 메서드를 호출하는 것이 아니라 현재 객체에게 메시지를 전송하는 것입니다.

 

self 전송

  • self 참조가 가리키는 자기 자신에게 메시지를 전송하는 것을 self 전송이라고 부릅니다.
  • self 전송을 이해하기 위해서는 self 참조가 가리키는 바로 그 객체에서부터 메시지 탐색을 다시 시작한다는 사실을 기억해야 합니다.
  • self 전송은 자식 클래스에서 부모 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킵니다.
  • 이로 인해 최악의 경우에는 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 발생할 수도 있습니다.
  • 모든 메시지는 컴파일타임에 확인되고 이해할 수 없는 메시지는 컴파일 에러로 이어집니다.
  • 컴파일 시점에 수신 가능한 메시지를 체크하기 때문에 이해할 수 없는 메시지를 처리할 수 있는 유연성은 잃게 되지만 실행 시점에 오류가 발생할 가능성을 줄임으로써 프로그램이 좀 더 안정적으로 실행될 수 있는 것입니다.

 

super

public class Animal {
    public void speak() {
        System.out.println("Animal speaks!");
    }
}

public class Dog extends Animal {
    @Override
    public void speak() {
        super.speak();  // 부모 클래스의 speak 메서드 호출
        System.out.println("Woof woof!");
    }

    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.speak();
        // 출력:
        // Animal speaks!
        // Woof woof!
    }
}
  • super 참조의 정확한 의도는 ‘지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요’입니다.
  • 만약 부모 클래스에서 원하는 메서드를 찾지 못한다면 더 상위의 부모 클래스로 이동하면서 메서드가 존재하는지 검사합니다.
  • 이것은 super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공합니다.
  • 따라서 self 전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만 super 전송의 경우에는 컴파일 시점에 미리 결정해 놓을 수 있습니다.
  • super 참조는 부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 합니다.

 

위임

// Printer 인터페이스
interface Printer {
    void print(String message);
}

// 실제 프린터 구현
class RealPrinter implements Printer {
    @Override
    public void print(String message) {
        System.out.println(message);
    }
}

// 프린터 위임자
class PrinterDelegate {
    private Printer printer;

    public PrinterDelegate(Printer printer) {
        this.printer = printer;
    }

    public void print(String message) {
        // 실제 print 작업은 delegate에게 위임
        printer.print(message);
    }
}

public class DelegationExample {
    public static void main(String[] args) {
        RealPrinter realPrinter = new RealPrinter();
        PrinterDelegate printerDelegate = new PrinterDelegate(realPrinter);

        printerDelegate.print("Hello, Delegation!");
    }
}
  • 자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것을 위임이라고 부릅니다.
  • 위임은 본질적으로는 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용합니다.
  • 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달합니다.
  • 상속은 동적으로 메서드를 탐색하기 위해 현재의 실행 문맥을 가지고 있는 self 참조를 전달합니다.
  • 그리고 이 객체들 사이에서 메시지를 전달하는 과정은 자동으로 이뤄지는데 이 과정을 자동적인 메시지 위임이라고 부릅니다.

 

 

마무리

오늘은 오브젝트 12장을 스터디하고 이해한 내용들을 정리해 보았습니다.

이번 장을 읽기 전에는 상속이 서브타입 계층을 구축하기 위해 사용한다고는 알고 있었지만 왜 그런지에 대해 잘 모르고 있었습니다. 읽고 나니 "어떻게 하면 상속의 서브타입 계층을 잘 구축해서 효율적으로 다형성을 사용할 수 있을지?" 에 대한 고민이 새로 생겨서 성장한 느낌이 드네요.

이번 포스팅은 마무리하면서 다음 포스팅에서 뵙겠습니다.

 

 

참고

http://www.yes24.com/Product/Goods/74219491

 

오브젝트 - 예스24

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라!객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두번째 걸음은 객체를

www.yes24.com

 

'스터디 > 오브젝트' 카테고리의 다른 글

14장 - 일관성 있는 협력  (1) 2023.10.26
13장 - 서브클래싱과 서브타이핑  (0) 2023.10.16
11장 - 합성과 유연한 설계  (0) 2023.08.28
10장 - 상속과 코드 재사용  (0) 2023.08.06
9장 - 유연한 설계  (0) 2023.07.24