1. 다형성 이해하기
프로그래밍에서 다형성(Polymorphism)은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 말한다. 다형성을 이해하기 위해서는 크게 2가지 핵심 이론을 알아야 한다. 다형적 참조와 메서드 오버라이딩을 학습해 보자.
다형적 참조
다형적 참조란 부모 타입의 변수가 자식 인스턴스를 참조할 수 있다는 것을 말한다.
- 부모 타입은 자신을 기준으로 모든 자식 타입을 담을 수 있다.
- 반대로 자식 타입은 부모 타입을 담을 수 없으며, 컴파일 오류가 발생한다.
public class PolyMain {
public static void main(String[] args) {
// 다형적 참조
System.out.println("Parent -> Child");
Parent poly = new Child(); // 부모는 자식을 담을 수 있음
poly.parentMethod();
// Child c1 = new Parent(); // 자식은 부모를 담을 수 없음
// poly.childMethod(); // 자식의 기능은 호출할 수 없음
}
}
위에서 Parent poly = new Child(); 부분을 보면, Child 인스턴스를 만들고, 생성된 참조값을 Parent 타입인 변수 poly에 담아둔다.
- 자식 타입인 Child를 생성했기 때문에 메모리 상에 Child와 Parent가 모두 생성된다.
- poly.parentMethod() 메서드를 호출하면, 먼저 참조값을 사용해서 인스턴스를 찾는다. 이때 poly는 Parent 타입이므로 Parent 클래스부터 시작해서 필요한 기능을 찾는다.
- 이렇게 자식을 참조한 상황에서 poly.childMethod()를 호출하면 컴파일 오류가 발생한다.
- 위와 동일하게 우선 참조값을 사용해서 인스턴스를 찾고, poly가 Parent 타입이므로 Parent 클래스부터 시작해서 필요한 기능을 찾는다.
- 그런데 상속 관계는 부모 방향으로 찾아 올라갈 수는 있지만, 그 반대로는 불가능하다. 따라서 childMethod()를 찾을 수 없으므로 컴파일 오류가 발생하게 된다.
- 이런 경우 childMethod()를 호출하고 싶다면 다운캐스팅을 사용해야 한다. 아래서 자세하게 알아보자.
다형성과 캐스팅
a. 다운캐스팅
부모 타입의 변수에 자식 인스턴스를 담아 사용하게 되면 자식 타입에 있는 기능은 호출할 수 없다. 자식엔 부모를 담을 수 없으므로 이때는 다운캐스팅이라는 기능을 사용해 부모 타입을 잠깐 자식 타입으로 변경하면 된다.
public class CastingMain {
public static void main(String[] args) {
// 다형적 참조
Parent poly = new Child();
// poly.childMethod(); // 자식 기능 사용 불가능
// 다운캐스팅 (부모 타입 -> 자식 타입
Child child = (Child) poly;
child.childMethod();
}
}
(타입)처럼 괄호와 그 사이에 타입을 지정하면 참조 대상을 특정 타입으로 변경할 수 있다. 위의 코드를 보면 Parent 타입인 poly를 (Child)를 사용해서 일시적으로 자식 타입으로 변경한 뒤 Child 타입 변수에 대입한다.
- 물론 캐스팅을 한다고 해서 Parent poly의 타입이 변하는 것은 아니다. 해당 참조값을 꺼내고, 꺼낸 참조값이 Child 타입이 되는 것이다. 따라서 poly의 타입은 Parent로 기존과 같이 유지된다.
// 1. 다운캐스팅 후 대입 시도
Child child = (Child) poly;
// 2. 참조값을 읽은 다음 자식 타입으로 지정
Child child = (Child) x001;
// 3. 결과
Child child = x001;
- 다운캐스팅 결과를 변수에 담아두는 과정이 번거롭다면 일시적으로 다운캐스팅을 해서 인스턴스 하위에 있는 클래스의 기능을 바로 호출할 수도 있다.
public class CastingMain {
public static void main(String[] args) {
Parent poly = new Child();
// 일시적 다운캐스팅 - 해당 메서드 호출 순간에만 캐스팅됨
((Child) poly).childMethod();
}
}
b. 업캐스팅
다운캐스팅과 반대로 자식 타입을 부모 타입으로 변경하는 것을 업캐스팅이라고 한다.
- 업캐스팅의 경우, 캐스팅 코드인 (타입)을 생략할 수 있다. 매우 자주 사용하기 때문에 생략을 권장한다.
public class CastingMain {
public static void main(String[] args) {
Child child = new Child();
Parent p1 = (Parent) child;
Parent p2 = child; // 업캐스팅 생략 가능
p1.parentMethod();
p2.parentMethod();
}
}
c. 다운캐스팅과 주의점
다운캐스팅은 잘못하면 심각한 런타임 오류가 발생할 수 있다. 코드를 통해 확인해 보자.
- new Parent()로 부모 타입 객체를 생성하면 메모리 상에 자식 타입은 존재하지 않는다.
- 생성한 참조값을 Parent 타입 변수에 담아두는 것까진 괜찮지만, Child 타입으로 다운캐스팅하면 메모리 상에 Child 자체가 존재하지 않으므로 아예 사용할 수 없다.
- 자바에서는 사용할 수 없는 타입으로 다운캐스팅하는 경우, ClassCastException이라는 예외를 발생시키고 프로그램을 종료한다.
public class CastingMain {
public static void main(String[] args) {
Parent p1 = new Child();
Child c1 = (Child) p1;
c1.childMethod();
Parent p2 = new Parent();
// Child c2 = (Child) p2; // 런타임 오류 - ClassCastException
// c2.childMethod(); // 실행 불가능
}
}
업캐스팅의 경우, 객체를 생성하면 해당 타입의 상위 부모 타입도 모두 함께 생성되기 때문에 메모리 상에 인스턴스가 모두 존재한다. 따라서 오류가 발생할 일이 없기 때문에 생략할 수 있다.
- 반면, 다운캐스팅은 메모리 상에 인스턴스가 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있기 때문에 명시적으로 캐스팅을 해줘야 한다.
instanceof
다형성에서 참조형 변수는 이름 그대로 다양한 자식을 대상으로 참조할 수 있다. 이때 해당 변수가 참조하는 인스턴스의 타입을 확인하고 싶다면 instanceof 키워드를 사용하면 된다.
- 다운캐스팅을 수행하기 전에는 먼저 instanceof를 사용해서 원하는 타입으로 변경이 가능한지 확인하는 게 좋다.
- parent instanceof Child 코드는 오른쪽 대상의 타입이나 자식 타입을 왼쪽에서 참조하는 경우에 true를, 아니라면 false를 반환한다.
public class CastingMain {
public static void main(String[] args) {
Parent p1 = new Parent();
call(p1);
Parent p2 = new Child();
call(p2);
}
private static void call(Parent parent) {
parent.parentMethod();
if (parent instanceof Child) { // 인스턴스 타입 확인
Child child = (Child) parent;
child.childMethod();
}
}
}
자바 16부터는 instanceof를 사용하면서 동시에 변수를 선언할 수 있다. 다음 코드를 참고하자.
public class CastingMain {
public static void main(String[] args) {
Parent p1 = new Parent();
call(p1);
Parent p2 = new Child();
call(p2);
}
private static void call(Parent parent) {
parent.parentMethod();
if (parent instanceof Child child) { // 변수 선언 및 타입 확인
child.childMethod(); // 자식 기능 바로 호출
}
}
}
다형성과 메서드 오버라이딩
메서드 오버라이딩에서 기억해야 할 점은 오버라이딩 된 메서드가 항상 우선권을 가진다는 것이다.
- Parent와 Child가 같은 이름의 멤버 변수와 메서드를 갖고 있다고 가정해 보자. 이때 Child는 Parent의 메서드를 오버라이딩한 상태다. 아래 코드가 어떻게 동작하는지 살펴보자.
- poly 변수는 Parent 타입이다. 따라서 poly.value와 poly.method()를 호출하면 인스턴스의 Parent 타입에서 기능을 찾아서 실행한다.
- poly.value = 변수는 오버라이딩 되지 않기 때문에 Parent 타입에 있는 value 값을 읽는다.
- poly.method() = 하위 타입인 Child.method()가 오버라이딩 돼 있다. 오버라이딩 된 메서드는 항상 우선권을 가지므로, Parent.method() 대신 Child.method()가 실행된다.
public class OverridingMain {
public static void main(String[] args) {
Parent poly = new Child();
System.out.println("value = " + poly.value); // 변수는 오버라이딩 X
poly.method(); // 메서드는 오버라이딩 O
}
}
2. 추상 클래스
추상 클래스
추상 클래스는 이름 그대로 추상적인 개념을 제공하는 클래스다. 따라서 실체인 인스턴스가 존재하지 않는다. 대신 상속을 목적으로 사용되며 부모 클래스 역할을 담당한다.
- 클래스를 선언할 때 앞에 abstract 키워드를 붙이면 된다.
public abstract class Animal {
}
추상 메서드
부모 클래스를 상속받는 자식 클래스가 반드시 오버라이딩해야 하는 메서드를 부모 클래스에 정의할 수 있다. 이것을 추상 메서드라고 한다.
- 추상 메서드는 실체가 존재하지 않고, 메서드 바디가 없다.
- 클래스와 동일하게 메서드를 선언할 때 앞에 abstract 키워드를 붙이면 된다.
- 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
- 추상 메서드는 메서드 바디가 없는 메서드를 가진 불완전한 클래스기 때문에 직접 생성하지 못하도록 반드시 추상 클래스로 선언해야 한다.
- 추상 메서드는 상속받는 자식 클래스가 반드시 오버라이딩해서 사용해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
- 바디 부분이 없는 이유는 자식 클래스가 반드시 오버라이딩해야 하기 때문이다.
- 오버라이딩 하지 않는다면 자식도 추상 클래스로 선언돼야 한다.
public abstract class Animal {
public abstract void sound();
public void move() {
System.out.println("move");
}
}
///
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("bow wow");
}
}
///
public class Cat extends Animal {
@Override
public void sound() {
System.out.println("meow");
}
}
순수 추상 클래스
모든 메서드가 추상 메서드인 추상 클래스를 말한다. 아래 예시 코드를 보면 순수 추상 클래스는 실행 로직이 전혀 없다. 그저 다형성을 위한 부모 타입으로써 껍데기 역할만 제공한다. 추가적인 특징은 아래와 같다.
- 인스턴스를 생성할 수 없다.
- 상속 시 자식은 모든 메서드를 오버라이딩해야 한다.
- 주로 다형성을 위해 사용된다.
public abstract class Animal {
public abstract void sound();
public abstract void move();
}
3. 인터페이스
인터페이스
자바는 순수 추상 클래스를 더 편리하게 사용할 수 있도록 인터페이스라는 개념을 제공한다. 위의 순수 추상 클래스 예시 코드에서 abstract class 대신 interface 키워드를 사용하면 된다. 인터페이스는 앞서 설명한 순수 추상 클래스에 편의 기능을 추가했다고 보면 된다.
- 인스턴스를 생성할 수 없다.
- 상속 시 자식은 모든 메서드를 오버라이딩 해야 한다.
- 주로 다형성을 위해 사용된다.
- 인터페이스의 메서드는 모두 public, abstract이다. 따라서 생략할 수 있고, 보통 생략이 권장된다.
- 인터페이스는 다중 구현(다중 상속)을 지원한다.
public interface Animal {
public abstract void sound(); // public abstract 생략 가능
public abstract void move(); // public abstract 생략 가능
}
추가로, 인터페이스에서 멤버 변수는 public, static, final이 모두 포함됐다고 간주한다.
public interface Animal {
public static final double MY_PI = 3.14; // public static final 생략 가능(권장)
}
인터페이스를 상속받을 때는 extends 대신 implements라는 키워드를 사용해야 하고, 상속 대신 구현한다고 표현한다.
- 상속은 부모의 기능을 물려받는 것이 목적이다. 그러나 인터페이스는 모든 메서드가 추상 메서드기 때문에 물려받을 기능이 없고, 오히려 모든 메서드를 오버라이딩해 기능을 구현하므로 '구현'이라고 표현한다.
- 인터페이스는 메서드 이름만 있는 설계도고, 실제 동작은 하위 클래스에서 모두 구현해야 한다.
public class Dog implements Animal {
@Override
public void sound() {
System.out.println("bow wow");
}
@Override
public void move() {
System.out.println("move!");
}
}
참고
클래스, 추상 클래스, 인터페이스는 프로그램 코드, 메모리 구조상 모두 똑같다. 모두 자바에서는 .class로 다뤄진다. 인터페이스를 작성할 때도 .java에 인터페이스를 정의한다.
참고
자바 8에 등장한 default 메서드를 사용하면 인터페이스도 메서드를 구현할 수 있다. 그러나 이것은 예외적으로 아주 특별한 경우에만 사용해야 한다.
자바 9에서 등장한 private 메서드도 마찬가지다.
인터페이스를 사용해야 하는 이유
- 제약: 인터페이스를 구현하려는 곳에서 인터페이스의 메서드를 반드시 구현하라는 규약(제약)을 주는 것이다. 순수 추상 클래스의 경우, 미래에 누군가가 실행 가능한 메서드를 끼워 넣을 수 있다. 이 경우엔 더 이상 순수 추상 클래스가 아니게 된다. 인터페이스는 모든 메서드가 추상 메서드이기 때문에 이런 문제를 차단할 수 있다.
- 다중 구현: 밑에서 자세히 설명한다.
다중 구현
자바에서 클래스 상속은 부모를 하나만 선택할 수 있다. 즉, 다중 상속을 지원하지 않는다. 대신 인터페이스의 다중 구현을 허용해 이런 문제를 피한다. 인터페이스의 다중 구현이 허용되는 이유는 인터페이스가 모두 추상 메서드로 이루어져 있기 때문이다. 아래 예제를 보면서 생각해 보자.
- InterfaceA와 InterfaceB는 같은 이름의 메서드를 갖고 있다. 그리고 Child는 두 인터페이스를 구현했다.
- 상속 관계의 경우, 두 부모 중 어떤 부모의 메서드를 사용해야 할지 결정해야 하는 다이아몬드 문제가 발생한다.
- 그러나 인터페이스 자신은 구현을 가지지 않고, 인터페이스를 구현하는 곳에서 해당 기능을 모두 구현해야 한다. InterfaceA와 InterfaceB가 같은 이름의 메서드를 갖고 있더라도 기능 구현은 Child에서 해야 한다. 추가로 오버라이딩에 의해 어차피 Child에 있는 메서드가 호출되므로, 결과적으론 Child의 메서드가 사용된다.
public class Child implements InterfaceA, InterfaceB {
@Override
public void methodA() {
...
}
@Override
public void methodB() {
...
}
@Override
public void methodCommon() {
...
}
}
클래스와 인터페이스 활용
클래스 상속과 인터페이스 구현을 함께 사용하는 예시를 살펴보자.
- AbstractAnimal = 추상 클래스(UML 실선 표시)
- 추상 메서드 sound()와 메서드 move() 제공
- Fly = 인터페이스(UML 점선 표시)
- 메서드 fly() 제공
코드로 살펴보면 아래와 같다.
public class Chicken extends AbstractAnimal implements Fly {
@Override
public void sound() {
System.out.println("Cock a doodle doo");
}
@Override
public void fly() {
System.out.println("chicken fly");
}
}
객체를 설계할 때, 자바 언어의 다형성을 활용해 역할(인터페이스)과 구현(구현 객체)을 분리하면 세상이 단순해지고, 유연해지며 변경도 편리해진다.
- 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
- 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
- 클라이언트는 구현 대상의 내부 구조가 변경돼도 영향받지 않는다.
- 클라이언트는 구현 대상 자체를 변경해도 영향받지 않는다.
더 자세한 설명은 아래 글을 참고하자.