개요
이번 장에서는 상속의 관점에서 다형적인 타입 계층을 구현하는 방법과 올바른 타입 계층을 구성하기 위해 고려해야 하는 원칙에 관해 소개합니다.
다형성(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() 메서드를 호출합니다.
- 객체지향 언어가 제공하는 업캐스팅과 동적 바인딩을 이용하면 부모 클래스 참조에 대한 메시지 전송을 자식 클래스에 대한 메서드 호출로 변환할 수 있습니다.
- 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정됩니다.
오버라이딩과 오버로딩
이전 블로그글 참고
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
'스터디 > 오브젝트' 카테고리의 다른 글
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 |