요약

벽의 콘센트는 모양만 맞으면 선풍기를 꽂든 충전기를 꽂든 전기를 준다. 코드에서도 똑같이 쓴 호출 한 줄이, 그 자리에 지금 어떤 객체가 꽂혀 있느냐에 따라 다른 일을 한다. 그래서 새 부품을 만들어 끼워도 쓰는 쪽 코드는 고치지 않는다. 다만 이것은 자리에 약속된 메서드를 부품이 자기 것으로 다시 만들어 두었을 때만 통한다. 약속에 없는 메서드는 부를 수 없고, 메서드가 아닌 변수는 꽂힌 부품이 아니라 자리에 적힌 이름을 따른다.

예시로 보기

자동차가 HankookTire tire처럼 특정 회사의 타이어 클래스를 필드 타입으로 박아 두면, 다른 회사 타이어로 바꿀 때 Car 클래스를 열어 고쳐야 한다. 필드 타입을 인터페이스 Tire로 두면 Car는 그대로 두고 타이어만 갈아 끼울 수 있다.

interface Tire {
    void roll();
}

class HankookTire implements Tire {
    @Override
    public void roll() { System.out.println("Hankook Tire"); }
}

class KumhoTire implements Tire {
    @Override
    public void roll() { System.out.println("Kumho Tire"); }
}

class Car {
    Tire tire = new HankookTire();
    void run() {
        tire.roll();
    }
}
Car myCar = new Car();
myCar.run();
myCar.tire = new KumhoTire();
myCar.run();
Hankook Tire
Kumho Tire
줄 myCar.tire에 든 객체 run() 안의 tire.roll()이 실행하는 것 출력
Car myCar = new Car(); HankookTire 객체 — —
myCar.run(); HankookTire 객체 HankookTire의 roll Hankook Tire
myCar.tire = new KumhoTire(); KumhoTire 객체 — —
myCar.run(); KumhoTire 객체 KumhoTire의 roll Kumho Tire

Car의 run()은 한 글자도 바뀌지 않았는데 출력이 달라졌다. 이렇게 같은 호출이 객체에 따라 다른 결과를 내는 성질이 다형성이다. 객체 지향의 세 원칙에서 이름만 본 그 원칙이 실제로는 이렇게 돌아간다.

콘센트 비유가 틀어지는 곳이 있다. 콘센트는 꽂힌 것이 무엇이든 같은 전기를 준다. 코드에서는 반대로 꽂힌 객체가 무슨 일을 할지 정한다. 자리는 “무엇을 부를 수 있는가”만 정한다.

검증: 위 출력, 매개변수 다형성의 출력, 새 클래스 Subway를 더했을 때, 필드와 정적 메서드가 변수 타입을 따르는 것, 카드 C3·C4·C5 — 29_java-polymorphism_impl.java

어떻게 돌아가는가

호출 한 줄은 두 번 정해진다

tire.roll(); 한 줄을 두고 컴파일러와 실행 환경이 서로 다른 것을 본다.

때 누가 무엇을 보나 정하는 것
컴파일할 때 컴파일러 변수의 타입(Tire) roll()을 불러도 되는가. Tire에 선언이 없으면 오류
실행할 때 자바 가상 기계 변수에 들어 있는 객체의 클래스(KumhoTire) 어느 몸통을 실행하는가

실행할 때 객체를 보고 몸통을 고르는 둘째 단계를 동적 바인딩이라고 부른다. 객체 A가 인터페이스의 메서드를 부르면, 실제로 그 일을 하는 것은 인터페이스 뒤에 있는 객체 B다. B를 C로 바꿔 끼우면 같은 호출이 C의 메서드를 실행한다. 위 예에서는 Car가 A이고, HankookTire와 KumhoTire 객체가 B와 C다.

다형성이 일어나려면 세 가지가 갖춰져야 한다.

  1. 구현 객체(또는 자식 객체)가 인터페이스(또는 부모) 타입 변수에 들어 있다. 자동 변환이다.
  2. 그 객체의 클래스가 메서드를 다시 정의했다. 메서드 오버라이딩이다.
  3. 그 변수로 메서드를 부른다.

필드 다형성

필드의 타입을 인터페이스로 두고, 그 필드에 여러 구현 객체를 바꿔 넣는 것이다. 예시로 보기의 Car와 Tire가 이것이다.

classDiagram
    class Car {
        Tire tire
        run()
    }
    class Tire {
        <<interface>>
        +roll()
    }
    class HankookTire {
        +roll()
    }
    class KumhoTire {
        +roll()
    }
    Car --> Tire : 필드로 가진다
    Tire <|.. HankookTire : implements
    Tire <|.. KumhoTire : implements

Car에서 나가는 화살표는 Tire에서 멈춘다. Car의 run()은 두 타이어 클래스의 이름을 모른다.

매개변수 다형성

메서드의 매개변수 타입을 인터페이스로 두고, 부를 때 여러 구현 객체를 넘기는 것이다.

interface Vehicle {
    void run();
}

class Driver {
    public void drive(Vehicle vehicle) {
        vehicle.run();
    }
}

class Bus implements Vehicle {
    @Override
    public void run() { System.out.println("Bus is running."); }
}

class Taxi implements Vehicle {
    @Override
    public void run() { System.out.println("Taxi is running."); }
}
Driver driver = new Driver();
Bus bus = new Bus();
Taxi taxi = new Taxi();

driver.drive(bus);
driver.drive(taxi);
Bus is running.
Taxi is running.

driver.drive(bus)로 부르는 순간 Vehicle vehicle = bus;와 같은 대입이 일어난다. 자동 변환이다. 그다음 vehicle.run()은 들어온 객체의 run을 실행한다.

sequenceDiagram
    participant M as main
    participant D as Driver
    participant B as Bus 객체
    participant T as Taxi 객체
    M->>D: drive(bus)
    Note over D: vehicle에 Bus 객체가 들어온다
    D->>B: vehicle.run()
    B-->>M: "Bus is running." 출력
    M->>D: drive(taxi)
    Note over D: vehicle에 Taxi 객체가 들어온다
    D->>T: vehicle.run()
    T-->>M: "Taxi is running." 출력

Driver 안의 코드 vehicle.run()은 두 번 다 같은 줄이다. 화살표가 닿는 곳만 다르다.

지켜 주는 것과 지켜 주지 않는 것

지켜 주는 것. 변수의 타입에 선언된 인스턴스 메서드를 부르면, 늘 변수에 든 객체의 클래스에서 다시 정의한 몸통이 실행된다. 변수의 타입이 무엇이든, 몇 번 다른 변수로 옮겨 담았든 같다. 그래서 Driver를 고치지 않고 새 탈것을 더할 수 있다.

class Subway implements Vehicle {
    @Override
    public void run() { System.out.println("Subway is running."); }
}

new Driver().drive(new Subway());는 Subway is running.을 찍는다. Driver는 Subway가 생기기 전에 쓴 코드 그대로다1.

지켜 주지 않는 것. 객체를 보고 고르는 것은 인스턴스 메서드뿐이다1.

class Parent {
    String name = "parent";
    String getName() { return name; }
    static String kind() { return "Parent"; }
}

class Child extends Parent {
    String name = "child";
    @Override
    String getName() { return name; }
    static String kind() { return "Child"; }
}
Parent p = new Child();
식 값 무엇을 따랐나
p.getName() child 든 객체(Child). 인스턴스 메서드
p.name parent 변수의 타입(Parent). 필드
p.kind() Parent 변수의 타입(Parent). 정적 메서드
  • 필드는 다시 정의되지 않는다. 자식이 같은 이름의 필드를 두면 부모의 필드를 가릴 뿐이고, 어느 쪽을 읽을지는 변수의 타입이 정한다.
  • 정적 메서드는 객체가 아니라 클래스에 붙어 있어서 객체를 보고 고를 것이 없다(static 멤버).
  • 변수의 타입에 없는 메서드는 객체에 있어도 부를 수 없다. 컴파일 단계에서 막힌다.

깨지는 경우

상황 언제 드러나나 결과
Vehicle을 구현하지 않은 객체를 drive에 넘김 컴파일할 때 incompatible types: Bike cannot be converted to Vehicle
변수의 타입에 없는 메서드를 부름 컴파일할 때 cannot find symbol
필드나 매개변수에 null이 들어 있음 실행할 때 NullPointerException

셋째가 가장 조심할 곳이다. Tire tire;로 선언만 하고 객체를 넣지 않으면 tire.roll()에서 프로그램이 멈춘다. 고를 객체가 없기 때문이다(참조 비교와 null)1.

스스로 설명해 보기

예시로 보기의 네 줄을 본다.

1. Tire tire = new HankookTire();가 컴파일되는 이유

HankookTire가 implements Tire로 선언되어 있어서 자동 변환이 된다. 세 조건 가운데 첫째다.

2. tire.roll();을 컴파일러가 통과시키는 이유

변수의 타입 Tire에 roll() 선언이 있어서다. 컴파일러는 tire에 어떤 객체가 들어 있을지는 보지 않는다.

3. 둘째 myCar.run()이 “Kumho Tire”를 찍는 이유

바로 앞 줄에서 myCar.tire가 가리키는 객체가 KumhoTire로 바뀌었다. 실행할 때 자바 가상 기계는 tire에 든 객체의 클래스에서 roll의 몸통을 찾는다. 그 클래스가 KumhoTire다.

이 구조의 핵심 아이디어는?

“무엇을 부를 수 있는가”는 변수의 타입이 컴파일할 때 정하고, “어느 몸통을 실행하는가”는 변수에 든 객체가 실행할 때 정한다. 둘을 떼어 놓았기 때문에 쓰는 쪽 코드를 그대로 두고 객체만 바꿀 수 있다.

이 방법을 쓸 수 있는 다른 상황은?

배열의 원소 타입을 인터페이스로 두고 여러 구현 객체를 섞어 담은 뒤 반복문에서 같은 메서드를 부른다. 메서드의 리턴 타입을 인터페이스로 두고 상황에 따라 다른 구현 객체를 돌려준다. 버튼이 눌렸을 때 할 일을 인터페이스 타입 필드에 넣어 둔다(중첩 클래스의 Button).

활용

  • 구현: 29_java-polymorphism_impl.java
  • 인터페이스 대신 부모 클래스나 추상 클래스를 변수의 타입으로 두어도 똑같이 돌아간다. Animal a = new Dog("바둑이"); a.sound();가 그 예다.
  • 시험에서 자주 묻는 꼴: 변수의 타입과 든 객체가 다른 코드를 주고 출력을 묻는다. 메서드 호출은 객체를, 필드와 정적 메서드는 변수의 타입을 따라가며 읽는다.

연결

자주 하는 오해

“변수의 타입이 실행될 메서드를 정한다”

틀렸다. 실행될 몸통은 변수에 들어 있는 객체의 클래스가 정한다. 그럴듯해 보이는 이유는 둘이다. 컴파일 오류는 변수의 타입을 기준으로 나기 때문에 타입이 모든 것을 정하는 것처럼 보인다. 또 필드와 정적 메서드는 정말로 변수의 타입을 따른다. 실제로는 호출 한 줄이 두 번 정해진다. 변수의 타입은 “부를 수 있는가”만 정하고, “무엇이 실행되는가”는 객체가 정한다. 확인하는 법: Parent p = new Child();에서 p.getName()은 child, p.name은 parent다. 같은 변수인데 메서드와 필드가 다른 쪽을 따른다.

확인 문제

C1 다형성이 일어나기 위한 세 조건을 쓰라.

답: ① 구현 객체(자식 객체)가 인터페이스(부모) 타입 변수에 들어 있다(자동 변환). ② 그 객체의 클래스가 메서드를 다시 정의했다(오버라이딩). ③ 그 변수로 메서드를 부른다.
이유: ①이 없으면 변수와 객체의 타입이 같아 바꿔 끼울 것이 없다. ②가 없으면 어느 객체든 같은 몸통이 실행된다. 흔한 오답은 ②를 빠뜨리는 것이다.

C2 Subway 클래스를 새로 만들었는데 Driver 클래스는 고치지 않아도 되는 이유를 쓰라.

답: Driver의 drive는 매개변수 타입이 Vehicle이고 그 안에서 Vehicle에 선언된 run()만 부른다. Subway가 Vehicle을 구현했으니 자동 변환으로 넘길 수 있고, run()의 몸통은 실행할 때 Subway 객체에서 찾는다.
이유: Driver의 코드 어디에도 Bus, Taxi, Subway라는 이름이 없다. 매개변수 타입이 Bus였다면 drive(Subway) 메서드를 따로 더해야 했다.

C3 본문의 Vehicle, Bus, Taxi, Subway가 있을 때 이 코드의 출력은?
Vehicle[] list = { new Taxi(), new Bus(), new Subway(), new Bus() };
for (Vehicle v : list) v.run();

답:

Taxi is running.
Bus is running.
Subway is running.
Bus is running.

이유: v.run()은 네 번 다 같은 줄이지만 그때마다 v에 든 객체의 run이 실행된다1.

C4 본문의 Parent, Child에서 Parent p = new Child();일 때 p.name, p.getName(), p.kind(), ((Child) p).name의 값을 쓰고, 어느 것이 객체를 따르고 어느 것이 변수의 타입을 따르는지 가르라.

답: parent, child, Parent, child. 객체를 따르는 것은 인스턴스 메서드 getName() 하나다. 필드 name과 정적 메서드 kind()는 변수의 타입을 따른다. 넷째는 강제 변환으로 식의 타입이 Child가 되어 Child의 필드를 읽는다.
이유: 다시 정의(오버라이딩)되는 것은 인스턴스 메서드뿐이다. 흔한 오답은 p.name을 child로 쓰는 것이다1.

C5 예시로 보기의 Car, Tire, HankookTire, KumhoTire가 있을 때 이 코드의 출력은?
Car car = new Car();
Tire t = car.tire;
car.tire = new KumhoTire();
t.roll();
car.run();

답:

Hankook Tire
Kumho Tire

이유: t는 처음의 HankookTire 객체를 가리킨다. car.tire에 새 객체를 넣어도 t가 가리키는 곳은 그대로다. car.run()은 car.tire에 지금 든 KumhoTire의 roll을 실행한다. 흔한 오답은 t.roll()도 Kumho Tire라고 쓰는 것이다1.

  1. 구현 코드와 JDK 21 컴파일로 확인했다(Java Language Specification §8.3의 필드 가리기, §8.4.8.2의 정적 메서드 가리기). ↩ ↩2 ↩3 ↩4 ↩5 ↩6