다형성 (Polymorphism)
다형성(Polymorphism) 이란, 그 자체의 의미를 표준 국어사전에서 찾아보면, '같은 종의 생물이면서도 어떤 형태나 형질이 다양하게 나타나는 현상' 이라 정의되어 있다.
이를 프로그래밍에서는, 다형성(polymorphism)이란 같은 자료형에 여러가지 타입의 데이터를 대입하여 다양한 결과를 얻어낼 수 있는 성질을 의미한다.
이를 통해 조상 클래스의 참조 변수로 자손 클래스의 참조 변수를 다루거나, 동일한 이름을 갖는 여러 형태의 매소드를 만들 수 있다.
자바에선 대표적으로 오버로딩, 오버라이딩, 업캐스팅, 다운캐스팅, 인터페이스, 추상메소드, 추상클래스 방법이 모두 다형성에 속하다고 생각하면 된다.
즉, 다형성은 클래스가 상속 관계에 있을때 나타나는 다채로운 성질 인 것이다.
실생활 제품을 비유해서 클래스 다형성을 이해해 보자.
LG회사의 전자제품 일반 TV와 스마트 TV를 예시를 들어보겠다.
다음 코드와 같이 TV 클래스와 SmartTV 클래스가 상속 관계를 맺었다고 가정하자.
TV 클래스에는 전원, 볼륨, 채널 켜기/끄기 기능밖에 없고, 스마트 TV 클래스에는 기본 TV 동작 메서드를 상속하고 추가적으로 AI 기능과 쇼핑 기능이 들어 있다.
class TV {
void powerOn_Off() {} // 전원 켜기/끄기
void volumeOn_Off() {} // 볼륨 켜기/끄기
void channelOn_Off() {} // 채널 돌리기
}
class SmartTV extends TV {
void AI_Assistance() {} // 인공지능 기능
void shopping() {} // 쇼핑하기
}
보통은 우리는 일반적으로 동일한 클래스 타입의 참조 변수를 생성해 초기화 하여 사용해왔다.
Tv tv_remotecontrol = new Tv();
SmartTv smart_remotecontrol = new SmartTv();
그렇지만 만일 부모 자식 상속 관계에 있으면 다음과 같이 부모 타입으로 자식 클래스 타입을 받아 초기화 할 수 있다.
TV tv_remoteControl = new SmartTV();
// Tv 클래스 타입의 참조변수 remoteControl를 선언한다.
// SmartTv의 인스턴스를 생성하고, 이 인스턴스의 주소값을 참조변수 remoteControl에 대입한다.
위의 상황을 그림으로 보면 아래와 같이 표현 할 수 있다.
마치 일반 텔레비전의 리모콘으로 스마트 티비를 조종하는 것과 같다. (TV 클래스 타입의 변수 이니까)
물론 해당 리모콘은 일반 TV에나 쓰는 리모콘이라 스마트한 기능은 조작하지 못한다.
그러나 같은 LG 회사의 제품이라서 기본적인 TV 기능(볼륨, 채널, 전원)은 호환이 된다고 한다.
따라서 사용할 수 있는 기능이 줄어든 것 뿐이지 스마트 TV 자체가 동작하는 것은 문제가 없다. (컴파일 에러가 없음)
그럼 반대 상황의 코드를 보자.
자식 클래스 타입의 참조 변수로 부모 클래스를 생성하여 초기화했다.
SmartTv smart_remotecontrol = new Tv();
위의 반대 상황을 그림으로 보면 아래와 같이 표현 할 수 있다.
스마트 티비 리모콘으로 일반 티비를 다루게 되는 것이다.
스마트 티비 리모콘은 일반 리모콘과는 달리 AI 기능을 이용할 수 있는 버튼과, 쇼핑을 즐길 수 있는 버튼이 있다.
그런데 만일 스마트 TV에서만 작동하는 기능을 누르면 어떻게 될까?
일반 티비는 적절하지 않은 버튼 동작 커맨드를 수신하게 되면 오류를 일으킬 것이다.
왜냐하면 TV에는 해당 기능 메소드가 없기 때문이다.
일반 TV이든 스마트 TV이든 리모콘이니까 기본 동작 자체는 되지 않을까 싶은데, 만일 소비자가 부주의로 일반 TV에는 없는 기능 버튼을 눌러 TV가 망가지게 된다면, 서비스 센터는 골치 아파질 것이다.
물론 소비자 부주의라 소비자 책임에 없잖아 있지만, 차라리 이럴바에 그냥 스마트 TV 리모콘으로는 일반 TV를 다루지 못해게 원천 차단 하는 것이 생각해보면 비용적으로 훨씬 효율적이고 이득이다.
따라서 개발자의 부주의로 인한 프로그램 에러를 원천 차단하기 위해 일반적으로 자바에서는 위의 코드는 문법적으로 옳지않다고 하여 컴파일 에러를 발생시킨다.
하지만 아무리 생각해도 스마트 TV 리모콘으로 일반 TV를 아예 못 다룬다는 것은 뭔가 불만이다.
안그러면 저기 멀리 매장에 가서 또 일반 리모콘을 사야되기 때문이다.
물론 추가 버튼을 눌러 TV가 망가질 수 있는 위험성이 존재하지만, 만일 소비자 부주의로 TV가 망가지게 되면 전적으로 소비자 책임으로 하기로 하고 스스로 조심하여 이용하면 되지 않을까 싶다.
이러한 기능이 바로 자바 객체 지향 클래스를 다루다 보면 한번쯤 들어보게 되는 UpCasting / DownCasting의 다운캐스팅(DownCasting) 부분이다.
Tv tv = new Tv();
SmartTv smart_remotecontrol = (SmartTv) tv;
그러나 사실 위의 코드는 빨간줄은 안쳐지지만 실제로 동작하지 않는다. (런타임 에러)
이에 대해서는 자바의 UpCasting / DownCasting을 자세히 소개하는 포스팅을 참고하길 바란다.
자료형 다형성
어쨌든 부모 클래스 타입으로 자식 클래스를 생성해 할당받을 수 있다는 사실은 배웠다.
그럼 이렇게 TV tv = new SmartTV() 선언해서 사용하는 것이 대체 어떠한 이득이 있길래 그렇게 다형성 다형성 거리는 것일까?
핵심은 바로 '타입 묶음' 이다.
다음과 같이 List를 만들어서 각각 Rectangle, Triangle, Circle 클래스 자료형을 저장할 수 있도록 하고, 객체 데이터를 만들어 넣은 후 이를 for 문을 이용해서 내용을 출력을 해보도록 하자.
타입에 맞는 리스트를 개별로 생성하고 add 와 forEach 를 통해 구현하였다.
class Rectangle {
}
class Triangle {
}
class Circle {
}
ArrayList<Rectangle> rectangles = new ArrayList<>();
rectangles.add(new Rectangle(1,2,3,4));
rectangles.add(new Rectangle(10,20,30,40));
rectangles.forEach(each -> System.out.println(each));
ArrayList<Triangle> triangles = new ArrayList<>();
triangles.add(new Triangle(1,2,3));
triangles.add(new Triangle(10,20,30));
triangles.forEach(each -> System.out.println(each));
ArrayList<Circle> circles = new ArrayList<>();
circles.add(new Circle());
circles.add(new Circle());
circles.forEach(each -> System.out.println(each));
한눈에 봐도 반복되는 느낌은 있지만 어찌하겠는가? Rectangle과 Color는 엄연히 타입이 다르기 때문에 이런식으로 구현해야 한다.
하지만 다음과 같이 Shape 라는 부모 클래스에 상속 관계를 맺게 되면, 만약 어떤 객체가 실제로는 Rectangle이라고 해도 외부에서 인식하기를 Shape이라면 그것은 Shape가 된다.
그 실체가 Rectangle이라는 사실은 그 객체 내부에서나 중요한 것이지 밖에서 그 객체를 사용하는 입장에서는 넘어가도 된다.
즉, 그 객체는 그냥 Shape일 뿐이게 된다.
이를 이용해 Shape 라는 자료형으로 묶어 코드를 확실히 압축 할 수 있다.
공통적인 상속 클래스의 특징을 이용해 각 자료형의 타입을 묶은 것이다.
class Shape {
}
class Rectangle extends Shape {
}
class Triangle extends Shape {
}
class Circle extends Shape {
}
ArrayList<Shape> shapes = new ArrayList<>();
shapes.add(new Rectangle(1,2,3,4));
shapes.add(new Rectangle(10,20,30,40));
shapes.add(new Triangle(1,2,3));
shapes.add(new Triangle(10,20,30));
shapes.add(new Circle());
shapes.add(new Circle());
shapes.forEach(each -> System.out.println(each));
만약 서로 다른 종류의 객체가 훨씬 더 많았더라면 더욱 많은 코드들이 사라졌을 것이다.
만일 각 자식 클래스에서 특수한 전용 메서드를 사용해야 한다고 하면, 메서드 오버라이딩을 통해 부모와 자식 클래스에 메서드를 구현해주거나 따로 그 요소만 빼서 다운캐스팅을 시켜 메서드를 실행해주면 된다.
매개변수 다형성
다형성의 특성은 꼭 변수의 타입 뿐만 아니라 인터페이스나 파라미터에서도 똑같이 적용된다.
다음 Tiger, Lion, Dog 클래스가 있고 이 클래스의 객체를 입력값으로 바다 bark() 메소드를 실행하면 각 객체의 인스턴스 변수 lang 을 참조해서 울음소리를 내는 프로그램을 다음과 같이 구성 하였다.
이때 bark() 메서드의 구성을 보면 매개변수마다 다른 객체 타입을 받아 실행하도록 되어 있어 메서드 오버로딩을 통해 구현됨을 볼 수 있다.
class Tiger {
String lang = "어흥";
}
class Lion {
String lang = "으르렁";
}
class Dog {
String lang = "멍멍";
}
class Bark {
// 메소드 오버로딩
void bark(Tiger tiger) {
System.out.println(tiger.lang);
}
void bark(Lion lion) {
System.out.println(lion.lang);
}
void bark(Dog dog) {
System.out.println(dog.lang);
}
}
public class Main2 {
public static void main(String[] args) {
Tiger tiger = new Tiger();
Lion lion = new Lion();
Dog dog = new Dog();
Bark command = new Bark();
command.bark(tiger); // 어흥
command.bark(lion); // 으르렁
command.bark(dog); // 멍멍
}
}
메서드 오버로딩이 나쁜 기법은 아니지만 그렇다고 좋은 방법도 아닌것이, 만일 Cat이라는 클래스를 추가한다고 가정하면 다음과 같이 bark() 메서드를 또 생성해주고 매개변수로 새로운 객체 타입을 받아야 한다.
그러다가 Lion 클래스를 삭제해야 한다면 다시 Bark 클래스의 메서드 구성을 수정해야 하는 번거로움이 생긴다.
// ...
// 만일 Cat 클래스가 새로 추가되면,
class Cat {
String lang = "냐옹";
}
// Bark 클래스의 메소드도 새로운 클래스 타입으로 새로 추가해 주어야 한다.
class Bark {
// ...
void bark(Cat cat) {
System.out.println(cat.lang);
}
// ...
}
따라서 다음고 같이 Animal 인터페이스를 생성하고 각 클래스마다 인터페이스를 구현하도록 지시 함으로써 보다 객체 지향 적으로 코드를 구성 할 수 있다.
인터페이스 구현을 통해 동물 클래스들을 구성했더니, Bark 클래스의 메서드가 Animal 타입을 받는 매개변수를 지닌 메서드 하나로 확 줄어들었음을 볼 수 있다.
interface Animal {
void start();
}
class Tiger implements Animal {
String lang = "어흥";
public void start() {
System.out.println(this.lang);
}
}
class Lion implements Animal {
String lang = "으르렁";
public void start() {
System.out.println(this.lang);
}
}
class Dog implements Animal {
String lang = "멍멍";
public void start() {
System.out.println(this.lang);
}
}
class Bark {
void bark(Animal animal) {
animal.start();
}
}
public class Main2 {
public static void main(String[] args) {
Tiger tiger = new Tiger();
Lion lion = new Lion();
Dog dog = new Dog();
Bark command = new Bark();
command.bark(tiger); // 어흥
command.bark(lion); // 으르렁
command.bark(dog); // 멍멍
}
}
이전에는 각 객체 타입을 일일히 받아내기 위해 메서드 오버로딩을 통해 메서드를 일일히 생성해 줘야 겠지만, 인터페이스를 통해 다형성을 구축해 놓으면 나중에 Cat 클래스가 추가되더라도 인터페이스를 구현 받고 구성해주면 더이상 Bark 클래스의 메서드를 업데이트 할 필요가 없어진다.
💡TIP
객체 지향 프로그래밍은 무언가를 구현하는 프로그래밍 기법이 아니라, 개발을 용이하게 해주는 설계 기법 이다.
// ...
// 추가할 클래스가 있다면 Animal 인터페이스를 구현만 해주면,
class Cat implements Animal {
String lang = "냐옹";
public void start() {
System.out.println(this.lang);
}
}
// Bark 클래스는 따로 건들 필요가 없다.
class Bark {
void bark(Animal animal) {
animal.start();
}
}
메서드 다형성
꼭 객체 타입 관점에서 뿐만 아리나 메서드를 확장하거나 재정의하는 overloading / overriding 도 메서드가 다형(多形) 해 지기 때문에 자바의 다형성 특징 중 하나에 속한다고 볼 수 있다.
class Parent {
// 오버로딩
public void print(int value) {
System.out.println("숫자 출력 = " + value);
}
// 오버로딩
public void print(String value) {
System.out.println("문자 출력 = " + value);
}
public void add(int x, int y) {
System.out.println(x + y);
}
}
class Child extends Parent {
// 오버라이딩
public void add(int x, int y) {
System.out.println((x + y) * 2);
}
}
class Main{
public void main(String[]args) {
Parent p = new Parent();
p.print(100); // 결과 : 숫자 출력 = 100 (오버로딩)
p.print("test"); // 결과 : 문자 출력 = "test" (오버로딩)
p.add(1,2); // 결과 : 3
Parent p2 = new Child();
p2.add(1,2); // 결과 : 6 (오버라이딩)
}
}
참고
- https://effectiveprogramming.tistory.com/entry/상속Inheritance에-대한-올바른-이해?category=660012
- https://peterdrinker.tistory.com/353