[Java] 7. 객체지향 프로그래밍 2 #5 다형성(polymorphism)

2022. 5. 6. 14:24JAVA/Language

1. 다형성이란 무엇인가?

  • 객체지향에서 다형성이란 조상 클래스 타입의 참조변수로 자손 클래스의 인스턴스를 참조할 수 있도록 하는 것을 의미합니다.
class TV{
	...
}

class CaptionTV extends TV{
	...
}

class DigitalTV extends TV{
	...
}

TV tv1 = new CaptionTV();	// 참조가 가능함 (o)
TV tv2 = new DigitalTV();	// 참조가 가능함 (o)
CaptionTV tv3 = new TV();	// 참조 불가능   (x)

 

다형성의 특징

  • 조상 클래스 타입의 참조변수로 자손 클래스의 인스턴스를 참조할 수 있음
  • 단, 위와 같이 저장한 조상 클래스 타입의 참조변수는 조상 클래스에 존재하는 필드멤버나 메서드밖에 사용할 수 없음. 즉, 자손 클래스의 필드멤버와 메서드를 사용할 수 없음

왜 조상 클래스 타입의 참조변수는 자손 클래스 타입의 인스턴스를 참조할 수 있음에도 조상 클래스의 멤버와 메서드만 사용할 수 있는가?

자손 클래스는 조상 클래스로부터 멤버들(필드멤버, 메서드)를 전부 상속받았기 때문에 자손 클래스의 멤버들의 개수가 조상 클래스의 멤버들보다 같거나 많기 때문에 조상 클래스 타입의 참조변수는 자손 클래스 타입의 인스턴스를 참조할 수 있습니다.

 

왜 자손 클래스 타입의 참조변수는 조상 클래스 타입의 인스턴스를 참조할 수 없는가?

예를 들어 다음과 같은 문장이 있다고 가정합니다.

CaptionTV c = new TV();

위 명령어는 자손 클래스 타입인 CaptionTV 타입의 참조변수가 조상 클래스인 TV 클래스의 인스턴스를 참조하고자 하는 명령어입니다. 하지만 위 명령어는 컴파일 에러가 발생합니다.

 

이유는 실제 인스턴스인 TV 인스턴스의 멤버 개수보다 참조변수 c가 사용할 수 있는 멤버 개수가 더 많기 때문입니다. 이는 CaptionTV 자손 클래스에 TV 클래스에 정의되어 있지 않은 멤버나 메서드를 정의하여 호출하게 된다면 TV클래스에는 존재하지 않기 때문에 문제가 발생할 것입니다.

 

2. 참조변수의 형변환

서로 상속관계에 있는 클래스 사이에서만 자손타입의 참조변수를 조상타입의 참조변수로, 조상 타입의 참조변수를 자손 타입의 참조변수로의 형변환만이 가능합니다.

 

자손타입->조상타입(up-casting) : 형변환 생략가능
자손타입<-조상타입(down-casting) : 형변환 생략불가

CaptionTV c = new Caption();
TV tv = c; // 형변환 생략

c = (CaptionTV) tv;	// 형변환 생략불가

 

형변환은 참조변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것은 아니기 때문에 참조변수의 형변환은 인스턴스에 아무런 영향을 미치지 않습니다.

 

단지 참조변수의 형변환을 통해서, 참조하고 있는 인스턴스에서 사용할 수 있는 멤버의 범위(개수)를 조절하는 것뿐입니다.

 

형변환 예제

class Car{
	String color;
	int door;
	
	public void drive() {
		System.out.println("drive~~~");
	}
	
	public void stop() {
		System.out.println("stop!!");
	}
}

class FireEngine extends Car{
	public void water() {	// 물을 뿌리는 기능
		System.out.println("water!!");
	}
}

public class CastingTest {
	public static void main(String[] args)
	{
		Car car = null;
		FireEngine fe = new FireEngine();
		FireEngine fe2 = null;
		
		fe.water();
		car = fe;	// car = (Car) fe;에서 형변환이 생략됨
//		car.water();	// Car 타입의 객체는 water()를 호출할 수 없음
		
		fe2 = (FireEngine) car;	// 자손타입 <- 조상타입
		fe2.water();
	}
}
water!!
water!!

위의 예제를 통해서 상속 관계에 있는 클래스들끼리 형 변환은 가능하지만 조상 클래스 타입의 car 인스턴스가 자손 클래스의 멤버인 water() 메서드를 호출할 수는 없습니다. 하지만 형변환을 통해서 fe2 참조변수가 FireEngine 타입의 인스턴스를 가리킬 수는 있습니다.

 

다음 예제는 반대로 자손 클래스 타입의 참조변수로 조상 클래스 타입의 인스턴스를 가리키는 예제입니다.

class Car{
	String color;
	int door;
	
	public void drive() {
		System.out.println("drive~~~");
	}
	
	public void stop() {
		System.out.println("stop!!");
	}
}

class FireEngine extends Car{
	public void water() {	// 물을 뿌리는 기능
		System.out.println("water!!");
	}
}

public class CastingTest {
	public static void main(String[] args)
	{
		Car car = new Car();
		Car car2 = null;
		FireEngine fe = null;
		
		car.drive();
		fe = (FireEngine) car;	// compile ok, runtime error
		fe.drive();
		car2 = fe;
		car2.drive();
	}
}
drive~~~
Exception in thread "main" java.lang.ClassCastException: class ch07.ex_16_casting2.Car cannot be cast to class ch07.ex_16_casting2.FireEngine (ch07.ex_16_casting2.Car and ch07.ex_16_casting2.FireEngine are in unnamed module of loader 'app')
	at ch07.ex_16_casting2.CastingTest.main(CastingTest.java:30)

위의 결과와 같이 컴파일은 성공하지만 런타임에 에러가 발생합니다. 에러가 발생한 이유는 이전 예제와는 다르게 car 인스턴스가 가리키는 클래스 타입이 자손 클래스인 FireEngine 클래스 타입이 아니라 Car 클래스이기 때문입니다. 이는 조상 타입의 인스턴스를 자손 타입의 참조변수로 참조하는 것은 허용되지 않기 때문에 런타임에 에러가 발생합니다.

 

위 문제를 해결하기 위해서 다음과 같이 수정할 수 있습니다.

public class CastingTest {
	public static void main(String[] args)
	{
		Car car = new FireEngine();	// 수정
		Car car2 = null;
		FireEngine fe = null;
		
		car.drive();
		fe = (FireEngine) car;	// 자손 클래스의 메서드를 호출하지 않고 형변환만 하기 때문에 가능함
		fe.drive();
		car2 = fe;
		car2.drive();
	}
}

 

형변환을 정리하면 다음과 같습니다.

서로 상속관게에 있는 타입간의 형변환은 양방향으로 자유롭게 수행될 수 있으나,
참조변수가 가리키는 인스턴스의 자손타입으로 형변환은 허용되지 않습니다.
Car car = new Car();
FireEngine fe = (FireEngine) car;	// 런타임시 에러

Car car = new FireEngine();
FireEngine fe = (FireEngine) car;	// 형변환 완료
그래서 참조변수가 가리키는 인스턴스의 타입이 무엇인지 확인하는 것이 중요합니다.

 

3. instanceof 연산자

  • instanceof 연산자는 참조변수가 참조하고 있는 인스턴스의 실제 타입을 확인하기 위해 사용함
  • 연산자 구성 : 참조변수 instanceof 클래스타입
  • 연산결과 : true = 참조변수가 검사한 타입으로 형변환이 가능함, false = 참조변수가 검사한 타입으로 형변환 불가능
  • 어떤 타입에 대한 instanceof 연산의 결과가 true라는 것은 검사한 타입으로 형변환이 가능하다는 것을 의미함

instanceof 연산자 예제

public class InstanceofTest {
	public static void main(String[] args) {
		FireEngine fe = new FireEngine();
		
		if(fe instanceof FireEngine) {
			System.out.println("This is a FireEngine instance.");
		}
		
		if(fe instanceof Car) {
			System.out.println("This is a Car instance");
		}
		
		if(fe instanceof Object) {
			System.out.println("This is an Object instance.");
		}
		System.out.println(fe.getClass().getName());	// 클래스 이름 출력
	}
}

class Car{
	
}
class FireEngine extends Car{
	
}
This is a FireEngine instance.
This is a Car instance
This is an Object instance.
ch07.ex_17_instanceof.FireEngine

위 결과를 보면 참조변수 fe는 FireEngine 타입이므로 Car 클래스와 Object 클래스의 자손 클래스 타입이므로 Car, Object 클래스로 형변환이 가능합니다.

Car c = fe;

Object o = fe;

 

4. 참조변수와 인스턴스의 연결

조상 타입의 참조 변수와 자손 타입의 참조 변수의 차이점

조상 타입의 참조 변수(Parent)와 자손 타입의 참조 변수(Child)가 자손 타입(Child)의 인스턴스를 가리킨다고 가정합니다.

Parent p = new Child();
Child c = new Child();

=> 조상 타입의 참조변수와 자손 타입의 참조 변수의 차이점은 참조 변수 타입에 따라 멤버변수의 참조가 달라진다는 점입니다. 조상 타입의 참조 변수는 조상 클래스의 멤버 변수를 참조하고 자손 클래스에 동일한 이름의 멤버 변수가 존재하면 자손 타입의 참조변수는 자손 클래스의 멤버 변수를 참조합니다. 하지만 메서드의 경우에는 참조변수의 타입(Parent, Child)에 관계없이 실제 인스턴스 메서드(오버라이딩된 메서드)가 호출됩니다. 

 

참조변수와 인스턴스의 연결 예제

public class BindingTest {
	public static void main(String[] args) {
		Parent p = new Child();
		Child c = new Child();
		
		System.out.println("p.x = " + p.x);	// 100
		p.method();	// 자손 클래스(Child)의 메서드 호출, "Child Method" 출력
		
		System.out.println("c.x = " + c.x);	// 200
		c.method();	// 자손 클래스에 오버라이딩된 메서드가 존재하면 자손 클래스의 메서드 호출
					// 만약 자손 클래스에 오버라이됭된 메서드가 존재하지 않으면 조상 클래스의
					// 메서드를 호출
	}
}

class Parent{
	int x = 100;
	
	public void method() {
		System.out.println("Parent Method");
	}
}

class Child extends Parent{
	int x = 200;
	
	public void method() {
		System.out.println("Child Method");
	}
}
p.x = 100
Child Method
c.x = 200
Child Method

위의 결과에서 p.x 참조시 실제 인스턴스인 Child 클래스의 멤버 변수 x=200이 아닌 조상 타입인 Parent 클래스의 멤버 변수인 x=100을 참조하는 것을 볼 수 있습니다. 하지만 메서드의 경우에는 실제 인스턴스의 메서드인 Child.method()를 호출 하는 것을 볼 수 있습니다. 이는 Child.method() 메서드가 오버라이딩된 메서드이기 때문에 다음과 같을 수 있는 것입니다. 만약 Parent 타입 참조변수가 자손 클래스의 오버라이딩 되지 않는 확장된 메서드를 호출시 컴파일 에러가 발생합니다.

 

왜 자손 클래스 타입의 인스턴스를 가리킴에도 조상 클래스 타입의 멤버변수를 가리키는가?

 

Parent p = new Child();
System.out.println(p.x);	// Expected Output : 100

=> 조상 클래스 타입의 참조변수가 자손 클래스 타입의 인스턴스를 가리키게 되고 조상 클래스에서 오버라이딩된 메서드를 호출하면 조상 클래스 타입을 가진 참조변수 입장에서는 호출할 수 있고(조상 클래스는 조상 클래스가 가진 멤버변수와 메서드만을 호출할 수 있음) 실제 가리키고 있는 주소에는 자손 클래스의 인스턴스가 저장되어 있기 때문에 오버라이딩된 메서드를 호출합니다. 그래서 조상 클래스 참조변수가 자손 클래스의 인스턴스를 저장하고 오버라이딩된 메서드를 호출할때 실제 인스턴스의 메서드(오버라이딩된)를 호출하는 이유입니다.

 

만약 자손 클래스에 조상 클래스와 중복되는 멤버 변수나 오버라이딩된 메서드 정의되지 않는 경우 어떻게 되는가?

=> 조상 타입 참조변수는 조상 클래스에 정의되어 있는 멤버 변수 참조하고 메서드를 호출합니다. 하지만 자손 타입의 참조 변수는 자손 클래스 자체에서 정의된 멤버 변수나 오버라이딩된 메서드가 없기 때문에 조상 클래스로 타고 올라가면서 조상 클래스의 멤버 변수와 메서드를 참조하거나 호출합니다.

public class BindingTest {
	public static void main(String[] args) {
		Parent p = new Child();
		Child c = new Child();
		
		System.out.println("p.x = " + p.x); // Expected Output : 100
		p.method();                         // Expected Output : Parent Method
		
		System.out.println("c.x = " + c.x); // Expected Output : 100
		c.method();                         // Expected Output : Parent Method							
	}
}

class Parent{
	int x = 100;
	
	public void method() {
		System.out.println("Parent Method");
	}
}

class Child extends Parent{
}
p.x = 100
Parent Method
c.x = 100
Parent Method

위 결과를 보면 자손 타입 참조 변수인 c는 멤버 변수 int x와 method() 메서드가 없으므로 조상 클래스인 Parent 클래스의 멤버 변수와 method() 메서드를 호출합니다.

 

5. 매개변수의 다형성

객체지향 개념의 다형성을 매개변수에 적용할 수 있습니다. 매개변수에 조상 타입의 참조변수를 매개변수로 정의하면 메서드 호출시 인자로 정의한 조상 클래스 타입 또는 자손 클래스 타입을 넣어서 전달할 수 있습니다.

 

매개변수의 다형성 예제

public class PolyArgumentTest {
	public static void main(String[] args) {
		Buyer b = new Buyer();
		
		b.buy(new TV());
		b.buy(new Computer());
		
		System.out.printf("현재 남은 돈은 %d 만원입니다.\n", b.money);
		System.out.printf("현재 보너스점수는 %d 점입니다.\n", b.bonusPoint);
	}
}

class Product{
	int price;		// 제품 가격
	int bonusPoint;	// 제품구매시 제공하는 보너스 점수
	
	public Product(int price) {
		this.price = price;
		this.bonusPoint = (int) (price * 0.1);
	}
}

class TV extends Product{
	TV(){
		// 조상 클래스(Product)의 생성자 Product(int price) 호출
		// TV의 가격을 100만원으로 설정
		super(100);	
	}

	@Override
	public String toString() {
		// Object 클래스의 toString()을 오버라이딩함
		return "TV";
	}
}

class Computer extends Product{
	public Computer() {
		super(200);
	}

	@Override
	public String toString() {
		return "Computer";
	}
}

class Buyer{	// 고객, 물건을 사는 사람
	int money = 1000;	// 소유금액
	int bonusPoint = 0; // 보너스점수
	
	// 매개변수로 Product 클래스와 Product 클래스의 자손 클래스들을 받을 수 있음
	public void buy(Product p) {	
		if(money < p.price) {
			System.out.println("잔액이 부족하여 물건을 살수 없습니다.");
			return;
		}
		money -= p.price;
		bonusPoint += p.bonusPoint;
		System.out.printf("%s 을/를 구입하였습니다.\n", p);
	}
}
TV 을/를 구입하였습니다.
Computer 을/를 구입하였습니다.
현재 남은 돈은 700 만원입니다.
현재 보너스점수는 30 점입니다.

위 예제에서 buy 메서드를 보면 매개변수로 Product 클래스 타입의 참조변수를 정의하여 Product 클래스 타입의 자손 클래스인 TV, Computer 자손 클래스 타입의 인스턴스를 전달받을 수 있습니다.


b.buy(new TV());

...

public void buy(Product p){
	money -= p.price;
    bounsPoint += p.bounsPoint;
}

 

6. 여러 종류의 객체를 배열로 다루기

Product 클래스의 자손 클래스들의 인스턴스들을 다음과 같이 저장할 수 있습니다.

Product p1 = new TV();
Product p1 = new Computer();
Product p1 = new Audio();

하지만 만약 Product 클래스의 자손 클래스가 100개라면 위와 같이 저장하기는 쉽지 않습니다. 따라서 다음과 같이 Product 객체들을 배열로 저장합니다.

Product[] item = new Product[10];

 

References

source code : https://github.com/yonghwankim-dev/java_study
Java의 정석, 남궁 성 지음