[Java의 정석]제7장 객체지향개념 2 - 5. 다형성(Polymorphism)

류명운

·

2014. 7. 3. 22:33

반응형

5. 다형성(Polymorphism)


5.1 다형성이란?

상속과 함께 객체지향개념의 중요한 특징중의 하나인 다형성에 대해서 배워 보도록 하자. 다형성은 상속과 깊은 관계가 있으므로 학습하기에 앞서 상속에 대한 충분히 알고 있어야 한다.

객체지향개념에서의 다형성이란 '여러 가지 형태를 가질 수 있는 능력'을 의미하며, 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 프로그램적으로 구현하였다.

이를 좀더 구체적으로 말하자면, 조상클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조할 수 있도록 하였다는 것이다. 예제를 통해서 보다 자세히 알아보도록 하자.


class Tv {
boolean power; // 전원상태(on/off)
int channel; // 채널

void power() { power = !power; }
void channelUp() { ++channel; }
void channelDown() { --channel; }
}

class CaptionTv extends Tv {
String text; // 캡션을 보여 주기 위한 문자열
void caption() { /* 내용생략 */}
}


Tv클래스와 CaptionTv클래스가 이와 같이 정의되어 있을 때, 두 클래스간의 관계를 그림으로 나타내면 아래와 같다.





클래스 Tv와 CaptionTv는 서로 상속관계에 있으며, 이 두 클래스의 인스턴스를 생성하고 사용하기 위해서는 다음과 같이 할 수 있다.


Tv t = new Tv();
CaptionTv c = new CaptionTv();


지금까지 우리는 생성된 인스턴스를 다루기 위해서, 인스턴스의 타입과 일치하는 타입의 참조변수만을 사용했다. 즉, Tv인스턴스를 다루기 위해서는 Tv타입의 참조변수를 사용하고, CaptionTv인스턴스를 다루기 위해서는 CaptionTv타입의 참조변수를 사용했다.
이처럼 인스턴스의 타입과 참조변수의 타입이 일치하는 것이 보통이지만, Tv와 CaptionTv 클래스가 상속관계에 있을 경우, 다음과 같이 조상클래스 타입의 참조변수로 자손클래스 타입의 객체를 참조하도록 하는 것도 가능하다.


Tv t = new CaptionTv();


그러면 이제 인스턴스를 같은 타입의 참조변수로 참조하는 것과 조상타입의 참조변수로 참조하는 것은 어떤 차이가 있는지에 대해서 알아보도록 하자.


CaptionTv c = new CaptionTv();
Tv t = new CaptionTv();


위의 코드에서 CaptionTv인스턴스 2개를 생성하고, 참조변수 c, t가 생성된 인스턴스를 하나씩 참조하도록 하였다. 이 경우 실제 인스턴스가 CaptionTv타입이라 할지라도, 참조변수 t로는 CaptionTv인스턴스의 모든 멤버를 사용할 수 없다.

Tv타입의 참조변수로는 CaptionTv인스턴스 중에서 Tv클래스의 멤버들(상속받은 멤버포함)만 사용할 수 있다. 따라서, 생성된 CaptionTv인스턴스의 멤버 중에서 Tv클래스에 정의 되지 않은 멤버, text와 caption()은 참조변수 t로 사용이 불가능하다. 즉, t.text 또는 t.caption()와 같이 할 수 없다는 것이다.



[참고] 실제로는 모든 클래스의 최고조상인 Object클래스로부터 상속받은 부분도 포함되어야 하지만 간단히 하기위해 생략했다.

반대로 아래와 같이 자손타입의 참조변수로 조상타입의 인스턴스를 참조하는 것은 가능할까?


CaptionTv c = new Tv(); // 컴파일 에러 발생


그렇지 않다. 위의 코드를 컴파일 하면 에러가 발생한다. 그 이유는 실제 인스턴스인 Tv의 멤버 개수보다 참조변수 c가 사용할 수 있는 멤버 개수가 더 많기 때문이다. 그래서 이를 허용하지 않는다.

CaptionTv클래스에는 text와 caption()이 정의되어 있으므로 참조변수 c로는 c.text, c.caption()과 같은 방식으로 c가 참조하고 있는 인스턴스에서 text와 caption()을 사용하려 할 수 있다.
하지만, c가 참조하고 있는 인스턴스는 Tv타입이고, Tv타입의 인스턴스에는 text와 caption()이 존재하지 않기 때문에 이들을 사용하려 하면 문제가 발생한다.

그래서, 자손타입의 참조변수로 조상타입의 인스턴스를 참조하는 것은 존재하지 않는 멤버를 사용하고자 할 가능성이 있으므로 허용하지 않는다. 참조변수가 사용할 수 있는 멤버의 개수는 인스턴스의 멤버 개수보다 같거나 작아야 하는 것이다.

[참고] 클래스는 상속을 통해서 확장될 수는 있어도 축소될 수는 없어서, 조상인스턴스의 멤버 개수는 자손인스턴스의 멤버 개수보다 항상 작거나 같다.

참조변수의 타입이 참조변수가 참조하고 있는 인스턴스에서 사용할 수 있는 멤버의 개수를 결정한다는 사실을 이해하는 것은 매우 중요하다.

그렇다면, 인스턴스의 타입과 일치하는 참조변수를 사용하면 인스턴스의 멤버들을 모두 사용할 수 있을 텐데 왜 조상타입의 참조변수를 사용해서 인스턴스의 일부 멤버만을 사용하도록 할까?

이에 대한 답은 앞으로 배우게 될 것이며, 지금은 조상타입의 참조변수로도 자손클래스의 인스턴스를 참조할 수 있다는 것과 그 차이에 대해서만 이해하면 된다.




5.2 참조변수의 형변환

기본형 변수와 같이 참조변수도 형변환이 가능하다. 단, 서로 상속관계에 있는 클래스사이에서만 가능하기 때문에 자손타입의 참조변수를 조상타입의 참조변수로, 조상타입의 참조변수를 자손타입의 참조변수로의 형변환이 가능하다.

[참고] 바로 윗 조상이나 자손이 아닌 간접적인 상속관계,예를 들면 조상의 조상,에 있는 경우에도 형변환이 가능하다. 따라서 모든 참조변수는 모든 클래스의 조상인 Object클래스 타입으로의 형변환이 가능하다.

기본형 변수의 형변환에서 작은 자료형에서 큰 자료형의 형변환은 생략이 가능하듯이, 참조형 변수의 형변환에서는 자손타입의 참조변수를 조상타입으로 형변환하는 경우에는 형변환을 생략할 수 있다.


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


조상타입의 참조변수를 자손타입의 참조변수로 변환하는 것을 다운캐스팅(Down-casting)이라고 하며, 자손타입의 참조변수를 조상타입의 참조변수로 변환하는 것을 업캐스팅(Up-casting)이라고 한다.

참조변수간의 형변환 역시 캐스트연산자를 사용하며, ()안에 변환하고자 하는 타입의 이름(클래스명)을 적어주면 된다.


class Car {
String color;
int door;
void drive() { // 운전하는 기능
System.out.println("drive, Brrrr~");
}
void stop() { // 멈추는 기능
System.out.println("stop!!!");
}
}

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

class Ambulance extends Car { // 앰뷸런스
void siren() { // 사이렌을 울리는 기능
System.out.println("siren~~~");
}
}


이와 같이 세 클래스, Car, FireEngine, Ambulance가 정의되어 있을 때, 이 세 클래스간의 관계를 그림으로 표현하면 아래와 같다.


[참고] 이처럼 클래스들간의 상속관계를 그림으로 나타내 보면, 형변환의 가능여부를 쉽게 확인할 수 있다.

Car클래스는 FireEngine클래스와 Ambulance클래스의 조상이다. 그렇다고 해서 FireEngine클래스와 Ambulance클래스가 형제관계는 아니다. 자바에서는 조상과 자식관계만 존재하기 때문에 FireEngine클래스와 Ambulance클래스는 서로 아무런 관계가 없는 것으로 간주된다.

따라서, Car타입의 참조변수와 FireEngine타입의 참조변수 그리고 Car타입의 참조변수와 Ambulance타입의 참조변수간에는 서로 형변환이 가능하지만, FireEngine타입의 참조변수와 Ambulance타입의 참조변수간에는 서로 형변환이 가능하지 않다.


FireEngine f;
Ambulance a;
a = (Ambulance)f; // 컴파일 에러!!!
f = (FireEngine)a; // 컴파일 에러!!!


먼저 Car타입의 참조변수와 FireEngine타입의 참조변수간의 형변환을 예로 들어보자.


Car car = null;
FireEngine fe = new FireEngine();
FireEngine fe2 = null;

car = fe; // car = (Car)fe;에서 형변환이 생략된 형태이다.
fe2 = (FireEngine)car; // 형변환을 생략할 수 없다.


참조변수 car와 fe의 타입이 서로 다르기 때문에, 대입연산(=)이 수행되기 전에 형변환을 수행하여 두 변수간의 타입을 맞춰 주어야 한다.
그러나, 자손타입의 참조변수를 조상타입의 참조변수에 할당할 경우 형변환을 생략할 수 있어서 car = fe;와 같이 하였다. 원칙적으로는 car = (Car)fe;와 같이 해야 한다.

반대로 조상타입의 참조변수를 자손타입의 참조변수에 할당할 경우 형변환을 생략할 수 없으므로, fe2 = (FireEngine)car; 와 같이 명시적으로 형변환을 해주어야 한다.

참고로 형변환을 생략할 수 있는 경우와 생략할 수 없는 경우에 대한 이유를 설명하자면 다음과 같다.
Car타입의 참조변수 c가 있다고 가정하자. 참조변수 c가 참조하고 있는 인스턴스는 아마도 Car인스턴스이거나 자손인 FireEngine인스턴스일 것이다.
Car타입의 참조변수 c를 Car타입의 조상인 Object타입의 참조변수로 형변환 하는 것은 참조변수가 다룰 수 있는 멤버의 개수가 실제 인스턴스가 갖고 있는 멤버의 개수보다 적을 것이 분명하므로 문제가 되지 않는다. 그래서 형변환을 생략할 수 있도록 한 것이다.
하지만, Car타입의 참조변수 c를 자손인 FireEngine타입으로 변환하는 것은 참조변수가 다룰 수 있는 멤버의 개수를 늘리는 것이므로, 실제 인스턴스의 멤버 개수보다 참조변수가 사용할 수 있는 멤버의 개수가 더 많아질 수 있으므로 문제가 발생할 가능성이 있다.
그래서 자손타입으로의 형변환은 생략할 수 없으며, 형변환을 수행하기 전에 instanceof연산자를 사용해서 참조변수가 참조하고 있는 실제 인스턴스의 타입을 확인하는 것이 안전하다.


형변환은 참조변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것은 아니기 때문에 참조변수의 형변환은 인스턴스에 아무런 영향을 미치지 않는다.
단지 참조변수의 형변환을 통해서, 참조하고 있는 인스턴스에서 사용할 수 있는 멤버의 범위(개수)를 조절하는 것 뿐이다.


[참고] 전에 예로 든 Tv t = new CaptionTv();도 Tv t = (Tv)new CaptionTv();의 생략된 형태이다.

만일 이해가 잘 안 간다면, Tv t = (Tv)new CaptionTv();는 아래의 두 줄을 간략히 한 것이라고 생각하면 이해가 될 것이다.


CaptionTv c = new CaptionTv();
Tv t = (Tv)c;


[예제7-14] CastingTest1.java

class CastingTest1 {
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();
}
}

class Car {
String color;
int door;
void drive() { // 운전하는 기능
System.out.println("drive, Brrrr~");
}
void stop() { // 멈추는 기능
System.out.println("stop!!!");
}
}

class FireEngine extends Car { // 소방차
void water() { // 물을 뿌리는 기능
System.out.println("water!!!");
}
}
[실행결과]
water!!!
water!!!

위 예제의 주요실행과정을 그림과 함께 자세히 살펴보자.

1. Car car = null;
Car타입의 참조변수 car을 선언하고 null로 초기화한다.



2. FireEngine fe = new FireEngine();
FireEngine인스턴스를 생성하고 FireEngine타입의 참조변수로 참조하도록 한다.



3. car = fe;
참조변수 fe가 참조하고 있는 인스턴스를 참조변수 car가 참조하도록 한다. fe의 값(fe가 참조하고 있는 인스턴스의 주소)이 car에 저장된다. 이때 두 참조변수의 타입이 다르므로 참조변수 fe가 형변환되어야 하지만 생략되었다.
이제는 참조변수 car를 통해서도 FireEngine인스턴스를 사용할 수 있지만, fe와는 달리, car는 Car타입이므로 Car클래스의 멤버가 아닌 water()는 사용할 수 없다.



4. fe2 = (FireEngine)car;
참조변수 car가 참조하고 있는 인스턴스를 참조변수 fe2가 참조하도록 한다. 이때 두 참조변수의 타입이 다르므로 참조변수 car를 형변환하였다. car에는 FireEngine인스턴스의 주소가 저장되어 있으므로 fe2에도 FireEngine인스턴스의 주소가 저장된다.
이제는 참조변수 fe2를 통해서도 FireEngine인스턴스를 사용할 수 있지만, car와는 달리, fe2는 FireEngine타입이므로 FireEngine인스턴스의 모든 멤버들을 사용할 수 있다.



[예제7-15] CastingTest2.java

class CastingTest2 {
public static void main(String args[]) {
Car car = new Car();
Car car2 = null;
FireEngine fe = null;

car.drive();
fe = (FireEngine)car; // 실행 시 에러가 발생한다.
fe.drive();
car2 = fe;
car2.drive();
}
}
[실행결과]
drive, Brrrr~
java.lang.ClassCastException: Car
at CastingTest2.main(CastingTest2.java:8)

이 예제는 컴파일은 성공하지만, 실행시 에러(ClassCastException)가 발생한다. 에러가 발생한 곳은 문장은 CastingTest2.java의 8번째 라인인 fe = (FireEngine)car;이며, 발생이유는 형변환에 오류가 있기 때문이다. 캐스트 연산자를 이용해서 조상타입의 참조변수를 자손타입의 참조변수로 형변환한 것이기 때문에 문제가 없어 보이지만, 문제는 참조변수 car가 참조하고 있는 인스턴스가 Car타입의 인스턴스라는데 있다. 전에 배운 것처럼 조상타입의 인스턴스를 자손타입의 참조변수로 참조하는 것은 허용되지 않기 때문이다.

위의 예제에서 Car car = new Car();를 Car car = new FireEngine();와 같이 변경하면, 컴파일시 뿐 만 아니라 실행 시에도 에러가 발생하지 않을 것이다.

컴파일시에는 참조변수간의 타입만 체크하기 때문에 실행 시 생성될 인스턴스의 타입에 대해서는 알지 못한다. 그래서 컴파일시에는 문제가 없었지만, 실행 시에는 에러가 발생하여 실행이 비정상적으로 종료된 것이다.

캐스트연산자를 사용하면, 서로 상속관계에 있는 클래스 타입의 참조변수간의 형변환은 양방향으로 자유롭게 수행될 수 있다. 단, 참조변수가 참조하고 있는 인스턴스의 타입보다 자손타입으로의 형변환은 허용되지 않는다.




5.3 instanceof연산자

참조변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 instanceof연산자를 사용한다.
주로 조건문에 사용되며, instanceof의 왼쪽에는 참조변수를 오른쪽에는 타입(클래스명)이 피연산자로 위치한다. 그리고 연산의 결과로 boolean값인 true, false 중의 하나를 반환한다.
instanceof를 이용한 연산결과로 true값을 얻었다는 것은 참조변수가 검사한 타입으로 형변환이 가능하다는 것을 뜻한다.


if (c instanceof FireEngine) { // c는 Car타입의 참조변수
FireEngine fe = (FireEngine)c;
fe.water();
//...
}


위의 코드는 instanceof연산자로 Car타입의 참조변수 c가 FireEngine타입의 인스턴스를 참조하고 있는지를 검사하고, 그 결과가 true이면, 형변환을 통해 FireEngine타입의 참조변수가 참조하도록 하여, FireEngine인스턴스의 멤버인 water()를 사용할 수 있도록 한 것이다.

조상타입의 참조변수로 자손타입의 인스턴스를 참조할 수 있기 때문에, 참조변수의 타입과 인스턴스의 타입이 항상 일치하지는 않는다는 것을 배웠다. 이 경우, 실제 인스턴스의 멤버들을 모두 사용할 수 없기 때문에, 참조변수의 형변환을 통해서 실제 인스턴스의 모든 멤버들을 사용할 수 있도록 할 수 있다.

[예제7-16] InstanceofTest.java

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.");
}
}
}
[실행결과]
This is a FireEngine instance.
This is a Car instance.
This is an Object instance.

비록 생성된 인스턴스는 FireEngine타입일지라도, Object타입과 Car타입의 instanceof연산에서도 true를 결과로 얻었다. 그 이유는 FireEngine클래스는 Object클래스와 Car클래스의 자손클래스이므로 조상의 멤버들을 상속받았기 때문에, FireEngine인스턴스는 Object인스턴스와 Car인스턴스를 포함하고 있는 셈이기 때문이다.
요약하면, 실제 인스턴스와 같은 타입의 instanceof연산 이외에 조상타입의 instanceof연산에도 true를 결과로 얻으며, instanceof연산의 결과가 true라는 것은 검사한 타입으로의 형변환을 해도 아무런 문제가 없다는 뜻이다.





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

조상타입의 참조변수와 자손타입의 참조변수의 차이점이 사용할 수 있는 멤버의 개수에 있다고 배웠다. 여기서 한가지 더 알아두어야 할 내용이 있다.

조상클래스에 선언된 멤버변수와 같은 이름의 멤버변수를 자손클래스에 중복으로 정의했을 때, 조상타입의 참조변수로 자손 인스턴스를 참조하는 경우와 자손타입의 참조변수로 자손인스턴스를 참조하는 경우 다른 결과를 얻는다.
메서드의 경우 조상클래스의 메서드를 자손의 클래스에서 오버라이딩한 경우에도 참조변수의 타입에 관계없이 항상 실제 인스턴스의 메서드(오버라이딩된 메서드)가 호출되지만, 멤버변수의 경우 참조변수의 타입에 따라 다르게 사용된다.

[참고] static메서드는 멤버변수처럼 참조변수의 타입에 영향을 받는다. 참조변수의 타입에 영향을 받지 않는 것은 인스턴스메서드 뿐이다.

결론부터 말하자면, 멤버변수가 조상클래스와 자손클래스에 중복으로 정의된 경우, 조상타입의 참조변수를 사용했을 때는 조상클래스에 선언된 멤버변수가 사용되고, 자손타입의 참조변수를 사용했을 때는 자손클래스에 선언된 멤버변수가 사용된다.

하지만, 중복 정의되지 않은 경우, 조상타입의 참조변수를 사용했을 때와 자손타입의 참조변수를 사용했을 때의 차이는 없다. 중복된 경우는 참조변수의 타입에 따라 달라지지만, 중복되지 않은 경우 선택의 여지가 없기 때문이다.

[예제7-17] BindingTest.java

class BindingTest {
public static void main(String[] args) {
Parent p = new Child();
Child c = new Child();

System.out.println("p.x = " + p.x);
p.method();

System.out.println("c.x = " + c.x);
c.method();
}
}

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

class Child extends Parent {
int x = 200;
void method() {
System.out.println("Child Method");
}
}
[실행결과]
p.x = 100
Child Method
c.x = 200
Child Method

타입은 다르지만, 참조변수 p, c모두 Child인스턴스를 참조하고 있다. 그리고, Parent클래스와 Child클래스는 서로 같은 멤버들을 정의하고 있다.
이 때 조상타입의 참조변수 p로 Child인스턴스의 멤버들을 사용하는 것과 자손타입의 참조변수 c로 Child인스턴스의 멤버들을 사용하는 것의 차이를 알 수 있다.
메서드인 method()의 경우 참조변수의 타입에 관계없이 항상 실제 인스턴스의 타입인 Child클래스에 정의된 메서드가 호출되지만, 멤버변수인 x는 참조변수의 타입에 따라서 달라진다.

[예제7-18] BindingTest2.java

class BindingTest2 {
public static void main(String[] args) {
Parent p = new Child();
Child c = new Child();

System.out.println("p.x = " + p.x);
p.method();

System.out.println("c.x = " + c.x);
c.method();
}
}

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

class Child extends Parent { }
[실행결과]
p.x = 100
Parent Method
c.x = 100
Parent Method

이전의 예제와는 달리 Child클래스에는 아무런 멤버도 정의되어 있지 않고 단순히 조상으로부터 멤버들을 상속받는다. 그렇기 때문에 참조변수의 타입에 관계없이 조상의 멤버들을 사용하게 된다.
이처럼 자손클래스에서 조상클래스의 멤버를 중복으로 정의하지 않았을 때는 참조변수의 타입에 따른 변화는 없다. 어느 클래스의 멤버가 호출되어야 할지, 즉 조상의 멤버가 호출되어야할 지, 자손의 멤버가 호출되어야할 지에 대해 선택의 여지가 없기 때문이다.
참조변수의 타입에 따라 결과가 달라지는 경우는 조상클래스의 멤버변수와 같은 이름의 멤버변수를 자손클래스에 중복해서 정의한 경우뿐이다.

[예제7-19] BindingTest3.java

class BindingTest3 {
public static void main(String[] args) {
Parent p = new Child();
Child c = new Child();

System.out.println("p.x = " + p.x);
p.method();

System.out.println("c.x = " + c.x);
c.method();
}
}

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

class Child extends Parent {
int x = 200;
void method() {
System.out.println("x=" + x); // this.x와 같다.
System.out.println("super.x=" + super.x);
System.out.println("this.x=" + this.x);
}
}
[실행결과]
p.x = 100
x=200
super.x=100
this.x=200
c.x = 200
x=200
super.x=100
this.x=200

자손클래스 Child에 선언된 멤버변수 x와 조상클래스 Parent로부터 상속받은 멤버변수 x를 구분하는데 참조변수 super와 this가 사용된다.
자손인 Child클래스에서의 super.x는 조상클래스인 Parent에 선언된 멤버변수 x를 뜻하며, this.x 또는 x는 Child클래스의 멤버변수 x를 뜻한다. 그래서 위 결과에서 x와 this.x의 값이 같다.

전에 배운 것과 같이 멤버변수들은 주로 private으로 접근이 제어되고, 메서드를 통해서 멤버변수에 접근하도록 하지, 이번 예제에서처럼 다른 외부 클래스에서 참조변수를 통해 가능하면 직접적으로 멤버변수에 접근할 수 있도록 하지는 않는다.
이 예제에서 알 수 있듯이 멤버변수에 직접 접근하면, 참조변수의 타입에 따라 사용되는 멤버변수가 달라질 수 있으므로 주의해야한다.



5.5 매개변수의 다형성

참조변수의 다형적인 특징은 메서드의 매개변수에도 적용된다. 아래와 같이 Product, Tv, Computer, Buyer클래스가 정의되어 있다고 가정하자.


class Product {
int price; // 제품의 가격
int bonusPoint; // 제품구매 시 제공하는 보너스점수
}
class Tv extends Product {}
class Computer extends Product {}
class Audio extends Product {}

class Buyer { // 고객, 물건을 사는 사람
int money = 1000; // 소유금액
int bonusPoint = 0; // 보너스점수
}


Product클래스는 Tv와 Computer클래스의 조상이며, Buyer클래스는 제품(Product)를 구입하는 사람을 클래스로 표현한 것이다.
Buyer클래스에 물건을 구입하는 기능의 메서드를 추가해보자. 구입할 대상이 필요하므로 매개변수로 구입할 제품을 넘겨받아야 한다. Tv를 살수 있도록 매개변수를 Tv타입으로 하였다.


void buy(Tv t) {
// Buyer가 가진 돈(money)에서 제품의 가격(t.price)만큼 뺀다.
money = money - t.price;
// Buyer의 보너스점수(bonusPoint)에 제품의 보너스점수(t.bonusPoint)를 더한다.
bonusPoint = bonusPoint + t.bonusPoint;
}


buy(Tv t)는 제품을 구입하면 제품을 구입한 사람이 가진 돈에서 제품의 가격을 빼고, 보너스점수는 추가하는 작업을 하도록 작성되었다. 그런데 의미론적으로는 buy(Tv t)는 Tv밖에 살 수 없기 때문에 아래와 같이 다른 제품들도 구입할 수 있는 메서드가 추가로 필요하다.


void buy(Computer c) {
money = money - c.price;
bonusPoint = bonusPoint + c.bonusPoint;
}

void buy(Audio a) {
money = money - a.price;
bonusPoint = bonusPoint + a.bonusPoint;
}



이렇게 되면, 제품의 종류가 늘어날 때마다 Buyer클래스에는 새로운 buy메서드를 추가해주어야 할 것이다.
그러나 메서드의 매개변수에 참조변수의 다형성을 이용하면 아래와 같이 하나의 메서드로 간단히 처리할 수 있다.


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


매개변수가 Product타입의 참조변수라는 것은, 메서드의 매개변수로 Product클래스의 자손타입의 참조변수면 어느 것이나 매개변수로 받아들일 수 있다는 뜻이다.
그리고, Product클래스에 price와 bonusPoint가 선언되어 있기 때문에 참조변수 p로 인스턴스의 price와 bonusPoint를 사용할 수 있기에 이와 같이 할 수 있다.

앞으로 다른 제품 클래스를 추가할때 Product클래스를 상속받도록 하기만 하면, buy(Product p)메서드의 매개변수로 받아들여질 수 있다.


Buyer b = new Buyer();
Tv t = new Tv();
Computer c = new Computer();
b.buy(t);
b.buy(c);


Tv클래스와 Computer클래스는 Product클래스의 자손이므로 위의 코드에서처럼, buy(Product p)메서드에 매개변수로 Tv인스턴스와 Computer인스턴스를 제공하는 것이 가능하다.

[예제7-20] PolyArgumentTest.java

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

class Tv extends Product {
Tv() {
// 조상클래스의 생성자 Product(int price)를 호출한다.
super(100); // Tv의 가격을 100만원으로 한다.
}

public String toString() { // Object클래스의 toString()을 오버라이딩한다.
return "Tv";
}
}

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

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

class Buyer { // 고객, 물건을 사는 사람
int money = 1000; // 소유금액
int bonusPoint = 0; // 보너스점수

void buy(Product p) {
if(money < p.price) {
System.out.println("잔액이 부족하여 물건을 살수 없습니다.");
return;
}
money -= p.price; // 가진 돈에서 구입한 제품의 가격을 뺀다.
bonusPoint += p.bonusPoint; // 제품의 보너스 점수를 추가한다.
System.out.println(p + "을/를 구입하셨습니다.");
}
}

class PolyArgumentTest {
public static void main(String args[]) {
Buyer b = new Buyer();
Tv tv = new Tv();
Computer com = new Computer();

b.buy(tv);
b.buy(com);

System.out.println("현재 남은 돈은 " + b.money + "만원입니다.");
System.out.println("현재 보너스점수는 " + b.bonusPoint + "점입니다.");
}
}
[실행결과]
Tv을/를 구입하셨습니다.
Computer을/를 구입하셨습니다.
현재 남은 돈은 700만원입니다.
현재 보너스점수는 30점입니다.

고객(Buyer)이 buy(Product p)메서드를 이용해서 제품, Tv와 Computer를 구입하고, 고객의 잔고와 보너스점수를 출력하는 예제이다.

한 가지 예를 더 들어 PrintStream클래스에 정의되어있는 print(Object o)메서드를 살펴보자. 매개변수로 Object타입의 변수를 선언하였다. Object클래스는 모든 클래스의 조상이므로 이 메서드의 매개변수로 어떤 타입의 인스턴스도 가능하므로, 이 하나의 메서드로 모든 타입의 인스턴스를 처리할 수 있는 것이다.
이 메서드는 o.toString()을 호출하여 문자열을 얻은 다음 문자열을 출력하는 일을 한다. 실제코드는 아래와 같다.


public void print(Object obj) {
write(String.valueOf(obj));
}

public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}





5.7 여러 종류의 객체를 하나의 배열로 다루기

조상타입의 참조변수로 자손타입의 객체를 참조하는 것이 가능하므로, Product클래스가 Tv, Computer, Audio클래스의 조상일 때, 다음과 같이 할 수 있는 것을 이미 배웠다.


Product p1 = new Tv();
Product p2 = new Computer();
Product p3 = new Audio();


위의 코드를 Product타입의 참조변수 배열로 하면 아래와 같다.


Product p[] = new Product[3];
p[0] = new Tv();
p[1] = new Computer();
p[2] = new Audio();


이처럼 조상타입의 참조변수 배열을 사용하면, 공통의 조상을 가진 서로 다른 종류의 객체를 배열로 묶어서 다룰 수 있다.
또는 묶어서 다루기를 원하는 객체들의 상속관계를 따져서 가장 가까운 공통조상 클래스 타입의 참조변수 배열을 생성해서 객체들을 저장하면 된다.

이러한 특징을 이용해서 예제 PolyArgumentTest.java의 Buyer클래스에 구입한 제품을 저장하기 위한 Product배열을 추가해보도록 하자.


class Buyer {
int money = 1000;
int bonusPoint = 0;
Product item[] = new Product[10]; // 구입한 제품을 저장하기 위한 배열
int i =0; // Product배열 item에 사용될 index

void buy(Product p) {
if(money < p.price) {
System.out.println("잔액이 부족하여 물건을 살수 없습니다.");
return;
}
money -= p.price;
bonusPoint += p.bonusPoint;
item[i++] = p; // 구입한 제품을 Product배열인 item에 저장한다.
System.out.println(p + "을/를 구입하셨습니다.");
}
}


구입한 제품을 담기 위해 Buyer클래스에 Product배열인 item을 추가해주었다. 그리고 buy메서드에 item[i++] = p;문장을 추가함으로써 물건을 구입하면, 배열 item에 저장되도록 했다.
이렇게 함으로써, 모든 제품클래스의 조상인 Product클래스 타입의 배열을 사용함으로써 구입한 제품을 하나의 배열로 간단하게 다룰 수 있게 된다.

[예제7-21] PolyArgumentTest2.java


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

Product() {
price = 0;
bonusPoint = 0;
}
}

class Tv extends Product {
Tv() {
// 조상클래스의 생성자 Product(int price)를 호출한다.
super(100);
}

public String toString() {
return "Tv";
}
}

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

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

class Audio extends Product {
Audio() {
super(50);
}

public String toString() {
return "Audio";
}
}

class Buyer { // 고객, 물건을 사는 사람
int money = 1000; // 소유금액
int bonusPoint = 0; // 보너스점수
Product item[] = new Product[10]; // 구입한 제품을 저장하기 위한 배열
int i =0; // Product배열에 사용될 카운터

void buy(Product p) {
if(money < p.price) {
System.out.println("잔액이 부족하여 물건을 살수 없습니다.");
return;
}
money -= p.price; // 가진 돈에서 구입한 제품의 가격을 뺀다.
bonusPoint += p.bonusPoint; // 제품의 보너스 점수를 추가한다.
item[i++] = p; // 구입한 제품을 Product배열인 item에 저장한다.
System.out.println(p + "을/를 구입하셨습니다.");
}

void summary() { // 구매한 물품에 대한 정보를 요약해서 보여 준다.
int sum = 0; // 구입한 물품의 가격합계
String itemList =""; // 구입한 물품목록
// 반복문을 이용해서 구입한 물품의 총 가격과 목록을 만든다.

for(int i=0; i < item.length; i++) {

if(item[i]==null) break;
sum += item[i].price;
itemList += item[i] + ", ";
}
System.out.println("구입하신 물품의 총금액은 " + sum + "만원입니다.");
System.out.println("구입하신 제품은 " + itemList + "입니다.");
}
}

class PolyArgumentTest2 {
public static void main(String args[]) {
Buyer b = new Buyer();
Tv tv = new Tv();
Computer com = new Computer();
Audio audio = new Audio();

b.buy(tv);
b.buy(com);
b.buy(audio);
b.summary();
}
}  

[실행결과]
Tv을/를 구입하셨습니다.
Computer을/를 구입하셨습니다.
Audio을/를 구입하셨습니다.
구입하신 물품의 총금액은 350만원입니다.
구입하신 제품은 Tv, Computer, Audio, 입니다.

[참고] 구입한 제품목록의 마지막에 출력되는 콤마(,)가 눈에 거슬린다면, itemList += item[i] + ", ";를 itemList += (i==0) ? "" + item[i] : ", " + item[i];과 같이 변경하자. 보다 깔끔한 결과를 얻을 수 있을 것이다.

위 예제에서 Product배열로 구입한 제품들을 저장할 수 있도록 하고 있지만, 배열의 크기를 10으로 했기 때문에 10개 이상의 제품을 구입할 수 없는 것이 문제다. 그렇다고 해서 배열의 크기를 무조건 크게 설정할 수 만은 없는 일이다.

이런 경우, Vector클래스를 사용하면 된다. Vector클래스는 내부적으로 Object타입의 배열을 가지고 있어서, 이 배열에 객체를 추가하거나 제거할 수 있게 작성되어 있다. 그리고, 배열의 크기를 동적으로 관리해주기 때문에 저장할 인스턴스의 개수에 신경 쓰지 않아도 된다.


public class Vector extends AbstractList implements List, Cloneable, java.io.Serializable {
protected Object elementData[];
...
}


[참고] Vector클래스는 이름 때문에 클래스의 기능을 오해할 수 있는데, 단지 동적으로 크기가 관리되는 객체배열이라고 생각하면 된다.

Vector클래스의 주요 메서드는 다음과 같다.



[예제7-22] PolyArgumentTest3.java

import java.util.*; // Vector클래스를 사용하기 위해서 추가해주었다.

class Tv extends Product {
Tv() { super(100); }
public String toString() { return "Tv"; }
}
class Computer extends Product {
Computer() { super(200); }
public String toString() { return "Computer"; }
}
class Audio extends Product {
Audio() { super(50); }
public String toString() { return "Audio"; }
}
class Buyer { // 고객, 물건을 사는 사람
int money = 1000; // 소유금액
int bonusPoint = 0; // 보너스점수
Vector item = new Vector(); // 구입한 제품을 저장하는데 사용될 Vector객체

void buy(Product p) {
if(money < p.price) {
System.out.println("잔액이 부족하여 물건을 살수 없습니다.");
return;
}
money -= p.price; // 가진 돈에서 구입한 제품의 가격을 뺀다.
bonusPoint += p.bonusPoint; // 제품의 보너스 점수를 추가한다.
item.add(p); // 구입한 제품을 Vector에 저장한다.
System.out.println(p + "을/를 구입하셨습니다.");
}

void refund(Product p) { // 구입한 제품을 환불한다.
if(item.remove(p)) { // 제품을 Vector에서 제거한다.
money += p.price;
bonusPoint -= p.bonusPoint;
System.out.println(p + "을/를 반품하셨습니다.");
} else { // 제거에 실패한 경우
System.out.println("구입하신 제품 중 해당 제품이 없습니다.");
}
}

void summary() { // 구매한 물품에 대한 정보를 요약해서 보여준다.
int sum = 0; // 구입한 물품의 가격합계
String itemList =""; // 구입한 물품목록
// 반복문을 이용해서 구입한 물품의 총 가격과 목록을 만든다.

if(item.isEmpty()) { // Vector가 비어있는지 확인한다.
System.out.println("구입하신 제품이 없습니다.");
return;
}

for(int i=0; i < item.size();i++) {
Product p = (Product)item.get(i); // Vector의 i번째에 있는 객체를 얻어 온다.
sum += p.price;
itemList += (i==0) ? "" + p : ", " + p;
}
System.out.println("구입하신 물품의 총금액은 " + sum + "만원입니다.");
System.out.println("구입하신 제품은 " + itemList + "입니다.");
}
}

class PolyArgumentTest3 {
public static void main(String args[]) {
Buyer b = new Buyer();
Tv tv = new Tv();
Computer com = new Computer();
Audio audio = new Audio();

b.buy(tv);
b.buy(com);
b.buy(audio);
b.summary();
System.out.println();
b.refund(com);
b.summary();
}
}
[실행결과]
Tv을/를 구입하셨습니다.
Computer을/를 구입하셨습니다.
Audio을/를 구입하셨습니다.
구입하신 물품의 총금액은 350만원입니다.
구입하신 제품은 Tv, Computer, Audio입니다.

Computer을/를 반품하셨습니다.
구입하신 물품의 총금액은 150만원입니다.
구입하신 제품은 Tv, Audio입니다.

Vector클래스를 사용하기 위해서 예제의 첫째 줄에 import java.util.*;를 추가해주었다.
그리고, 구입한 물건을 다시 반환할 수 있도록 refund(Product p)를 추가하였다. 이 메서드가 호출되면, 구입물품이 저장되어 있는 item에서 해당제품을 제거한다.

반응형