객체지향에서 가장 중요한 개념에는 상속과 다형성이 있다. 그 중에 다형성에 대해 알아보자.
다형성이란 사전적 의미로는 여러가지 형태를 가질 수 있는 능력을 말한다. 자바에서는 이 다형성을 한 타입의 참조변수가 여러 타입의 객체를 참조할 수 있도록 하여 다형성을 구현한다.
앞의 내용을 복습해보자면 참조변수는 인스턴스의 주소를 가리키는 기능을 하며, 인스턴스의 주소값(=위치값)을 저장한다. 다형성은 A 타입으로 선언된 참조변수 a가 B,C,D 등의 여러 타입의 객체를 참조할 수 있는 것이다.
여기 예시로 상속관계에 있는 부모 클래스와 자식 클래스가 있다.
class Parent{
int a;
int b;
}
class Child extends Parent{
int c;
int d;
int e;
}
Parent 클래스와 Child 클래스가 정의되어 있을 때 관계를 그림으로 표현하면 다음과 같다.

이 그림대로라면, 부모 클래스 타입으로 참조변수를 선언할 경우 자식 클래스의 멤버를 사용할 수 없을 것이다. 반대로, 자식 클래스 타입으로 참조변수를 선언할 경우 부모 클래스의 멤버도 사용 가능하다.
Parent p = new Child();
Child c = new Child();
// p.c 불가능
// p.d 불가능
// p.e 불가능
참조하는(가리키는) 인스턴스의 타입은 p,c 둘 다 같지만 참조변수를 선언한 타입이 다르기 때문에 사용할 수 있는 멤버의 개수가 다르다.
- Parent 타입으로 참조변수를 선언하면 Child 인스턴스 중 Parent의 멤버만 사용할 수 있다
- Child 타입으로 선언할 경우 Child 인스턴스의 모든 멤버 사용이 가능하다
만약, 자식 클래스 타입으로 참조변수를 선언하고 Parent 클래스의 인스턴스를 참조할 경우에는 컴파일 에러가 발생한다.
Child c = new Parent(); // 컴파일 에러 발생
왜 컴파일 에러가 발생할까? 알기 쉽게 그림으로 설명하겠다.
Parent p = new Child();

이 경우에는 선언한 참조변수의 타입이 참조한 인스턴스에 존재하기 때문에 참조가 가능하다.

Child c = new Child();
마찬가지로 선언한 Child 타입이 인스턴스에 존재하기 때문에 컴파일 에러가 발생하지 않는다.

Parent p = new Parent();
2번째 경우와 마찬가지이다.

Child c = new Parent();
Child 타입으로 선언했지만 인스턴스는 Parent이기 때문에 만약, Child의 멤버(존재하지 않는 멤버)를 사용하고자 할 경우에는 해당 멤버가 없기 때문에 에러가 발생한다.
쉽게 말하자면, Child로 선언하여 더 많은 데이터를 원하지만(c,d,e까지) 실제 있는 데이터는 더 적은 상황인 것이다.
이러한 이유로 참조변수가 사용할 수 있는 멤버의 개수는 참조하는 인스턴스의 멤버 개수보다 같거나 작아야 하는 것이다.
정리
- 조상 타입의 참조변수는 자손 타입의 인스턴스를 참조할 수 있다
- 자속 타입의 참조변수는 조상 타입의 인스턴스를 참조할 수 없다
int a = 10;
long b;
b = (long)a;
기본형 자료형에서 형변환에 대해 다룬 적이 있는데 이와 마찬가지로 참조변수에서도 형변환이 가능하다.
단, 상속 관계에 있는 클래스 사이에서만 가능하다는 조건이 있다.
- 자손 타입 -> 조상 타입 : Up-Casting
- 조상 타입 -> 자손 타입 : Down-Casting
형변환은 캐스팅 연산자를 사용하여 할 수 있다.
class Computer{}
class Mouse extends Computer{}
class Keyboard extends Computer{}
Child1 과 Child2 클래스는 CommonParent 라는 공통의 조상 클래스를 상속한다.
Computer c = null;
Mouse m1 = new Mouse();
Mouse m2 = null;
c = m1; // Up-Casting 형변환 생략 가능 (Mouse -> Computer)
m2 = (Mouse)c // Down-Casting 형변환 필수 (Computer -> Mouse)
참조변수 c와 m1은 선언 타입이 다르기 때문에 대입 연산자(=)이 수행되기 전에 형변환을 수행하여 두 변수 간의 타입을 맞춰주어야 한다.
근데 여기서 한 가지 의문이 있다. 왜 Down-Casting은 생략하면 안될까?
위의 예시에서 Computer 타입의 참조변수는 Mouse 타입의 참조변수보다 사용할 수 있는 멤버 수가 더 적다. 조상 타입으로 선언된 참조변수를 자손 타입으로 형변환을 하면 실제 인스턴스의 개수보다 참조변수가 사용할 수 있는 멤버의 개수가 더 많아지므로 문제가 발생할 수 있다.
예를 들어, Computer c = new Computer(); 로 초기화 되어 있을 경우 참조변수 c의 선언 타입이 Mouse가 되면 문제가 발생할 수 있다. 그래서, Down-Casting 간에는 캐스팅 연산자를 명시해야한다.
형변환은 참조변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것이 아니다. 그래서, 참조변수의 형변환은 인스턴스에 아무런 영향을 미치지 않는다.
이러한 특성 때문에 발생하는 문제가 있다. 컴파일 시에는 문제가 없는데 실행 시 에러가 발생하는 것이다.
다음 예제를 보자.
class Car{}
class Wheel extends Car{}
public class example{
public static void main(String args[]){
Car c = new Car();
Wheel w = null;
w = (Wheel)c;
}
}
이 코드는 컴파일은 성공하지만 실행 시 ClassCastException이 발생한다. 컴파일러는 참조변수 간의 타입만 체크하기 때문에 실행 시 생성될 인스턴스 타입에 대해서는 전혀 알지 못한다.
위의 예시에서 참조변수 c는 Car 인스턴스를 가리키고 있다. 하지만, Down-Casting을 하여 자손 클래스 타입인 Wheel 타입으로 선언을 하면 Wheel c = new Car(); 가 되는 것이다.
앞에 그림으로 설명했지만 자식 클래스 타입으로 선언하고 부모 클래스의 인스턴스를 가리키는 경우에는 실행 시 에러가 발생한다고 하였다. Wheel 타입에서 요구하는 데이터가 더 많을 수 있기 때문에 인스턴스를 조상 클래스인 Car로 가리키는 것은 불가능하기 때문이다.
컴파일과 실행 모두 문제가 없으려면 코드를 다음과 같이 수정해야 한다.
class Car{}
class Wheel extends Car{}
public class example{
public static void main(String args[]){
Car c = new Wheel();
Wheel w = null;
w = (Wheel)c;
}
}
참조변수 c가 참조하는 인스턴스를 Wheel 클래스의 인스턴스로 변경하면 해결된다.
참조변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 instanceof 연산자를 사용할 수 있다.
"참조변수 instanceof 인스턴스" 형태로 사용하며 boolean 값을 출력한다.
true가 출력되면 해당 인스턴스로 형변환이 가능 즉, 해당 인스턴스 타입으로 형변환을 해도 문제가 없다는 의미이다.
class Plane{}
class Engine extends Plane{}
public class InstanceofTest {
public static void main(String[] args) {
Engine e = new Engine();
if(e instanceof Plane) {
System.out.println("e는 Plane 타입으로 형변환 가능");
}
Plane p = new Plane();
if(p instanceof Engine) {
System.out.println("p는 Engine 타입으로 형변환 가능");
}
}
}
이처럼 주로 조건문에 사용되는 연산자이며 참조변수 p는 Plane 인스턴스를 참조하고 있기 때문에 Engine 타입으로 형변환이 불가하여 조건문이 실행되지 않음을 알 수 있다.
instanceof 연산자 외에도 "참조변수.getClass().getName()" 을 사용하면 참조변수가 참조하고 있는 인스턴스의 클래스명을 String형으로 반환한다.
package polymorphism;
class Plane{}
class Engine extends Plane{}
public class InstanceofTest {
public static void main(String[] args) {
Plane p = new Engine();
System.out.println(p.getClass().getName());
}
}
코드를 실행하면 polymorphism.Engine 이 출력되는데 패키지명.클래스명으로 반환되어 그 값을 println으로 출력한 것이다.
조상클래스와 자손클래스의 메서드가 중복될 경우 메서드 오버라이딩으로 인해 조건이 부합한다면 자손클래스의 메서드로 덮여진다(=오버라이딩 된다).
하지만, 멤버변수가 중복될 경우에는 어떻게 될까?
결론적으로 말하면 멤버변수는 오버라이딩이 발생하지 않는다.
앞에서 상속을 다뤘을 때 super를 사용하여 조상 클래스의 멤버를 호출할 수 있었다. 하지만, 참조변수의 선언 타입에 따라 호출되는 멤버변수가 달라진다.
다음 예시를 보자.
class Parent{
int a = 1;
void method() {
System.out.println("Parent Class");
}
}
class Child extends Parent{
int a = 2;
void method() {
System.out.println("Child Class");
}
}
public class InstanceofTest {
public static void main(String[] args) {
Parent p = new Child();
Child c = new Child();
System.out.println("p.a = " + p.a);
System.out.println("c.a = " + c.a);
p.method();
c.method();
}
}
멤버변수 a와 메서드 method는 중복되어 있는 상태이고 참조변수 p,c 모두 Child 인스턴스를 참조하고 선언 타입만 다른 상태이다.
코드를 실행시키면 다음과 같은 결과가 출력된다.
p.a = 1
c.a = 2
Child Class
Child Class
method의 경우 오버라이딩이 발생하여 부모 타입으로 선언하여도 자손 클래스의 메서드가 호출되었다.
멤버변수 a의 경우 참조변수 선언 타입에 따라 다른 값이 호출되었다.
참조변수 p의 경우 Parent 타입으로 선언하였기 때문에 Parent 클래스에서 선언된 멤버변수가 호출된다. 참조변수 c의 경우에는 그 반대의 결과가 나온다.
- 메서드의 경우 Dynamic Dispatch(동적 디스패치)를 사용하기 때문에 참조변수의 선언 타입이 아닌 참조하는 객체의 실제 타입에 따라 결정된다.
- 필드는 메서드와 다르게 Static Dispatch(정적 디스패치)를 따르기 때문에 참조변수의 선언 타입에 따라 결정된다.
보통은 멤버변수는 private 접근제어자를 붙여서 사용하기 때문에 외부에서는 메서드를 통해 접근하지만,
이처럼 참조변수의 타입에 따라 사용될 수 있는 인스턴스 변수가 달라질 수 있기 때문에 이 점을 유의해야 한다.
매개변수에도 다형적인 특징이 적용된다.
다음 예제를 보자.
class Product{
int price;
int bonuspoint;
Product(int price){
this.price = price;
bonuspoint = (int)(price/10.0);
}
}
class Computer extends Product{
Computer(){
super(100);
}
}
class Audio extends Product{
Audio(){
super(50);
}
}
물건을 사면 돈이 차감되고 보너스 포인트가 쌓이는 코드를 다시 구성해보자. 구매자인 Buyer 클래스에는 buy 메서드가 있는데 물건을 살 때 돈이 차감되고 보너스 포인트가 올라간다.
class Buyer{
int money = 1000;
int bonuspoint;
void buy(Computer c) {
if(money < c.price) {
return;}
money -= c.price;
bonuspoint += c.bonuspoint;
}
void buy(Audio a) {
if(money < a.price) {
return;}
money -= a.price;
bonuspoint += a.bonuspoint;
}
}
이렇게 매개변수로 참조변수를 받고 멤버를 호출하여 기능을 구현할 수 있다. 근데 Computer, Audio 외에 다른 제품이 추가될 때마다 buy 메서드를 계속 오버로딩하여 만들어야 할까?
코드도 많아지고 일일히 번거로워진다. 이럴때 매개변수의 다형적인 특징을 사용하면 이 문제점을 해결할 수 있다.
Computer, Audio 클래스 모두 Product 클래스를 상속하기 때문에 Product 클래스의 자손 타입의 참조변수면 전부 매개변수로 사용가능하다.
"void buy(Product p)" 이렇게 매개변수를 바꿔주면 Product 클래스를 상속하는 모든 클래스의 타입을 참조변수로 사용가능하다.
class Buyer{
int money = 1000;
int bonuspoint;
void buy(Product p) {
if(money < p.price) {
return;}
money -= p.price;
bonuspoint += p.bonuspoint;
}
}
코드의 양이 훨씬 줄어들고 간결해진 것을 알 수 있다.
여러 객체를 한 배열 내에 보관할 수 있다.
Product p1 = new Product(100);
Product p2 = new Product(200);
Product p3 = new Product(300);
Product[] p = new Product[3];
p[0] = p1;
p[1] = p2;
p[2] = p3;
System.out.println(p[0].price); //100
같은 타입의 배열에 여러 객체 즉, 인스턴스를 보관하고 호출이 가능하다.
여기서도 다형성이 적용되는데 공통 조상을 가지는 서로 다른 종류의 객체를 배열로 묶어서 다룰 수 있다.
Product[] p = new Product[3];
p[0] = new Computer();
p[1] = new Audio();
p[2] = new Computer(340);
System.out.println(p[1].bonuspoint);
다형성을 이용하면 공통된 조상만 가지면 되기 때문에 묶어서 다루고 싶은 객체들의 상속관계를 따져서 가장 가까운 조상클래스 타입의 참조변수 배열을 생성해서 객체를 저장하면 된다.
이를 좀 더 응용해보자.
void buy(Product p) {
if(money < p.price) {
return;}
money -= p.price;
bonuspoint += p.bonuspoint;
item[i++] = p;
System.out.println(p.price + "원 차감 되었습니다." + " 현재 잔액은 " + money + "원입니다.");
System.out.println(p.bonuspoint + "점 추가되었습니다." + " 보너스 포인트는 " + bonuspoint + "점입니다.");
System.out.println();
}
buy 메서드를 수정하여 매개변수로 받은 객체를 item 배열에 추가하게 하였다.
Buyer b = new Buyer();
b.buy(new Computer());
b.buy(new Audio());
b.buy(new Audio());
b.buy(new Audio());
b.buy(new Audio());
buy 메서드를 사용할 때마다 잔액과 보너스 포인트의 현 상태가 출력되며 현재 오디오에 꽂혀 Flex를 해보았다.
100원 차감 되었습니다. 현재 잔액은 900원입니다.
10점 추가되었습니다. 보너스 포인트는 10점입니다.
50원 차감 되었습니다. 현재 잔액은 850원입니다.
5점 추가되었습니다. 보너스 포인트는 15점입니다.
50원 차감 되었습니다. 현재 잔액은 800원입니다.
5점 추가되었습니다. 보너스 포인트는 20점입니다.
50원 차감 되었습니다. 현재 잔액은 750원입니다.
5점 추가되었습니다. 보너스 포인트는 25점입니다.
50원 차감 되었습니다. 현재 잔액은 700원입니다.
5점 추가되었습니다. 보너스 포인트는 30점입니다.
코드가 잘 작동하는 것을 알 수 있다.
void summary() {
String itemList = "";
for(int i = 0; i < item.length; i++) {
itemList += item[i] + ", ";
}
System.out.println("현재 구입하신 물품은 " + itemList + " 입니다.");
}
여기에 summary 메서드를 추가하여 itemList 변수에 item 객체가 순서대로 저장되어 String 형태로 출력되도록 하였다.(그 전에 모든 클래스에 toString 메서드를 오버라이딩 함)
현재 구입하신 물품은 Computer, Audio, Audio, Audio, Audio, null, null, null, null, null, 입니다.
이렇게 객체 배열의 다형성을 사용하면 공통 조상의 객체를 묶어서 관리가 가능하다.
하지만, 여기에 한 가지 문제점이 있다. 배열의 크기를 지정하였기 때문에 10개 이상의 객체를 넣을 수가 없다.
이 때, Vector 클래스를 사용하면 배열의 크기를 알아서 관리해주기 때문에 저장할 인스턴스 개수를 신경쓰지 않아도 된다.
Vector 클래스에 대해서는 이후에 컬렉션 프레임워크에서 자세히 알아보겠다.