[OOP] 다형성(Polymorphism)이란?
안녕하세요? 제이온입니다.
이번 시간에는 객체 지향 패러다임의 4원칙(캡슐화, 다형성, 상속, 추상화) 중의 하나인 다형성에 대해 알아보겠습니다.
다형성이란?
위키피디아에 따르면, 다형성을 아래와 같이 정의하고 있습니다.
프로그램 언어의 다형성은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, 식, 오브젝트, 함수, 메소드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다. 반댓말은 단형성으로, 프로그램 언어의 각 요소가 한가지 형태만 가지는 성질을 가리킨다.
쉽게 말하면, 다형성이란 하나의 객체에 여러 가지 타입을 대입할 수 있다는 것을 의미합니다. 반대로, 단형성은 하나의 객체에 하나의 타입만 대응할 수 있습니다. 다형성을 설명하기 전에 단형성을 사용한 코드를 보겠습니다.
예를 들어, 특정한 자료형을 문자열로 바꾼다고 가정합시다. 그렇다면, 숫자나 날짜 자료형을 문자열로 바꾸기 위하여 아래와 같은 메소드들을 정의할 수 있습니다.
//숫자를 문자열로 바꾸는 경우
String age = stringFromNumber(number);
//날짜를 문자열로 바꾸는 경우
String today = stringFromDate(date);
숫자를 문자열로 바꿀 때는 stringFromNumber(), 날짜를 문자열로 바꿀 때는 stringFromDate()를 사용하는 것이죠. 이것은 단형성 체계의 언어에서는 함수의 이름이 중복될 수 없기 때문입니다. 여기서 눈치가 빠르신 분은 자바에서는 오버로딩을 사용하여 함수의 이름이 중복되어도 무방하다는 것을 알고 계실 겁니다.
아무튼, 위와 같이 비슷한 기능을 하는 함수의 이름을 자료형에 따라 끝도 없이 나열한다면 상상만 해도 끔찍하겠죠? 그래서 다형성 체계의 언어에서는 함수의 이름을 같게 하여 위의 작업을 아래와 같이 간결하게 만들 수 있습니다. (물론, 함수의 이름이 무조건 같다는 것은 아닙니다! 이 부분은 아래에서 더 자세히 설명하겠습니다.)
//숫자를 문자열로 바꾸는 경우
String age = stringValue(number);
//날짜를 문자열로 바꾸는 경우
String today = stringValue(date);
물론, 여러 자료형에 따라 문자열로 바꾸는 작업 자체가 줄어든 것은 아니지만 메소드 하나의 이름만 가지고도 기억하기 쉽고 메소드의 이름을 절약한다는 장점이 있습니다. 개발을 하다보면 특히 이 메소드나 변수명을 이름 짓기가 빡센데 그 부담을 덜어주는 것이죠.
위 사례는 어디까지나 다형성의 하나의 예시이고, 지금부터 다형성을 구현하는 방법을 알아봅시다.
다형성을 구현하는 방법
다형성을 구현하는 방법은 대표적으로 오버로딩, 오버라이딩, 함수형 인터페이스를 사용하는 것이 있습니다. 하나씩 예시를 통해 설명하겠습니다.
(1) 오버로딩
첫 번째는 위에서 언급했던 오버로딩입니다. 메소드 오버로딩은 한 클래스 내에 이미 사용하는 이름의 메소드가 있더라도 특정 규칙을 지킨다면 동일한 이름의 메소드를 정의하도록 허용하는 기술을 말합니다. 여기서 말하는 특정 규칙은 아래와 같습니다.
1. 메소드의 이름이 같아야 한다.
2. 매개 변수의 개수 또는 타입이 달라야 한다.
3. 매개 변수는 같고, 리턴 타입이 다를 때는 성립하지 않는다.
4. 오버로딩된 메소드들은 매개 변수로만 구분될 수 있다.
4가지 장황하게 써놨지만, 핵심은 매개 변수의 타입, 개수, 순서 중에 하나 이상 달라야 합니다. 위 오버 로딩을 구현한 실제 JDK 코드로는 String.valueOf()가 있습니다. 제가 처음 다형성을 소개할 때 그 stringValue()와 똑같은 역할을 하는 메소드입니다.
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
public static String valueOf(char data[]) {
return new String(data);
}
public static String valueOf(char data[], int offset, int count) {
return new String(data, offset, count);
}
public static String valueOf(boolean b) {
return b ? "true" : "false";
}
public static String valueOf(char c) {
if (COMPACT_STRINGS && StringLatin1.canEncode(c)) {
return new String(StringLatin1.toBytes(c), LATIN1);
}
return new String(StringUTF16.toBytes(c), UTF16);
}
public static String valueOf(int i) {
return Integer.toString(i);
}
public static String valueOf(long l) {
return Long.toString(l);
}
public static String valueOf(float f) {
return Float.toString(f);
}
public static String valueOf(double d) {
return Double.toString(d);
}
제가 예시로 들었던 날짜를 문자열로 바꿔주는 메소드는 없지만, 그래도 상당히 많은 자료형을 문자열로 바꿔주는 작업을 해 주고 있습니다. 그리고 매개 변수가 동일한 메소드는 하나도 존재하지 않습니다. 이러한 오버로딩을 통해 우리는 다형성을 구현해 줄 수 있습니다.
하지만, 메소드 오버로딩을 사용하는 경우 요구 사항이 바뀌었을 때 모든 메소드를 수정할 수도 있으므로 꼭 필요한 경우에만 사용해야 합니다. 저는 개인적으로 일반적인 메소드보다는 생성자 오버로딩을 주로 사용하는 편입니다.
public class Station {
private Long id;
private String name;
public Station() {
}
public Station(String name) {
this(null, name);
}
public Station(Long id, String name) {
this.id = id;
this.name = name;
}
}
위와 같이 Station의 필드를 초기화해 주는 조건이 여러 가지일 수 있는데, 생성자 오버로딩을 통해 쉽게 구현할 수 있습니다. 거기다가 this()를 통한 생성자 체이닝까지 해 준다면 더 좋겠죠?
(2) 오버라이딩
메소드 오버라이딩은 상위 클래스의 메소드를 재정의하는 것을 의미합니다. 주로, 클래스 상속이나 인터페이스 상속을 통해 구현할 수 있습니다. 또한, 개발하면서 다형성 구현이다하면 이러한 상속을 사용하는 편이 많습니다. 저는 이번 포스팅에서 인터페이스 상속을 예시로 들어보겠습니다.
예를 들어, 운송 수단의 종류인 자동차, 비행기, 기차가 있다고 합시다. 모두 움직일 수 있으므로 move() 메소드를 각각 아래와 같이 정의할 수 있습니다.
public class Car {
public void move() {
System.out.println("도로로 달립니다.");
}
}
public class Airplane {
public void move() {
System.out.println("하늘을 납니다.");
}
}
public class Train {
public void move() {
System.out.println("선로로 주행합니다.");
}
}
그리고 각각 운송수단들을 움직여 보겠습니다.
public class Main {
public static void main(String[] args) {
final Car car = new Car();
final Airplane airplane = new Airplane();
final Train train = new Train();
car.move();
airplane.move();
train.move();
}
}
잘 출력되는 군요! 그런데, 마냥 기뻐해서는 안 됩니다. 우리는 각각의 운송수단을 움직이기위하여 서로 다른 객체를 만들어 주었습니다. 그리고 move() 메소드를 각 객체마다 실행해 주었습니다. 만약 100가지의 운송수단이 생긴다면 move() 메소드를 100번 손으로 쳐야 합니다. 왜냐하면 이들을 반복문으로 묶어줄 수도 없기 때문이죠. 이때, 오버라이딩을 사용하면 위 문제를 해결할 수 있습니다.
우선, Movable라는 인터페이스를 만들겠습니다. 이 인터페이스의 메소드로는 move()가 있고, Car, Tarin, Airplane에 상속해 줍니다. 그리고 하위 클래스들은 각각 move() 메소드를 오버라이딩합니다.
public interface Movable {
void move();
}
public class Car implements Movable {
@Override
public void move() {
System.out.println("도로로 달립니다.");
}
}
public class Airplane implements Movable {
@Override
public void move() {
System.out.println("하늘을 납니다.");
}
}
public class Train implements Movable {
@Override
public void move() {
System.out.println("선로로 주행합니다.");
}
}
이제 Movable라는 객체는 Car가 될 수도 있고, Airplan이 될 수도 있고, Train이 될 수도 있습니다. 맨 처음에 말씀드렸던 "한 객체에 여러 가지 타입을 대응할 수 있다."를 몸소 보여주는 예시인 것이죠. 그래서 아래와 같이 다형성을 이용하여 Car, Airplane, Train의 move() 메소드를 호출할 수 있습니다.
public class Main {
public static void main(String[] args) {
final List<Movable> movables = Arrays.asList(new Car(), new Train(), new Airplane());
for (final Movable movable : movables) {
movable.move();
}
}
}
마찬가지로 잘 출력되는 것을 확인할 수 있습니다. 위와 같이 오버라이딩을 이용하면 비슷한 로직을 줄일 수 있다는 큰 장점이 있습니다.
(3) 함수형 인터페이스
함수형 인터페이스는 주로 Enum에서 빛을 발합니다. 먼저, 함수형 인터페이스란, 한 개의 추상 메소드를 가지고 있는 인터페이스를 말합니다. 위의 오버라이딩 예시에서 언급했던 Movable도 함수형 인터페이스에 속합니다. 그리고 이것은 람다식으로 간단히 표현이 가능합니다.
public class Main {
public static void main(String[] args) {
final List<Movable> movables = Arrays.asList(new Car(), new Train(), new Airplane(),
() -> System.out.println("길을 걷습니다."));
for (final Movable movable : movables) {
movable.move();
}
}
}
이런 식으로 람다식을 통해 하나 뿐인 추상 메소드를 정의하여 사용할 수 있습니다.
이렇게 우리가 만든 인터페이스도 함수형 인터페이스가 될 수 있지만, 자바에서 제공하는 대표적인 함수형 인터페이스들이 있습니다. 그리고 그 종류로는 Predicate, Consumer, Function, Supplier, XXXOperator 등이 있습니다. 앞의 XXX 붙은 것은 접두사를 의미합니다. 각각의 사용법이 궁금하신 분은 이곳을 참고하시길 바랍니다.
자, 그러면 Enum에 이 함수형 인터페이스를 어떻게 적용한다는 말일까요? 먼저, 함수형 인터페이스를 적용하지 않은 코드를 봅시다.
public enum DiscountRate {
TWENTIES(19, 0, 0),
TEENAGERS(13, 350, 0.2),
PRESCHOOLER(6, 350, 0.5),
BABIES(0, 0, 1);
private final int ageBaseline;
private final int deductionFare;
private final double discountRate;
DiscountRate(int ageBaseline, int deductionFare, double discountRate) {
this.ageBaseline = ageBaseline;
this.deductionFare = deductionFare;
this.discountRate = discountRate;
}
public static DiscountRate compareAgeBaseline(int age) {
if (TWENTIES.ageBaseline <= age) {
return TWENTIES;
}
if (TEENAGERS.ageBaseline <= age) {
return TEENAGERS;
}
if (PRESCHOOLER.ageBaseline <= age) {
return PRESCHOOLER;
}
return BABIES;
}
public int calculateFare(int currentFare) {
return (int) ((currentFare - deductionFare) * (1 - discountRate));
}
}
위는 나이에 따른 할인 정책을 의미하는 Enum 클래스입니다. 19세 이상에 대해서는 할인이 없고, 13세 이상 19세 미만은 20%, 6세 이상 13세 미만은 50%, 그 미만은 100%로 설정되어 있습니다. 그리고 0%나 100%가 아닌 할인률에 대해서는 공제 요금이 존재합니다. 예컨대, 지불할 요금이 1350원인데, 나이가 7세라면 '(1350-350) * 0.5' = 500이 되는 것입니다.
자, 여기서 주목해야할 부분은 compareAgeBaseline() 메소드입니다. 해당 메소드는 ageBaseline 값과 age를 비교하면서 if문을 나열하고 있습니다. 만약, 할인 정책이 늘어난다면 해당 분기는 끝도 없이 늘어날 것입니다. 이를 함수형 인터페이스인 Predicate를 통해 해결해 보겠습니다.
public enum DiscountRate {
ADULTS(0, 0, (age) -> 19 <= age),
TEENAGERS(350, 0.2, (age) -> 13 <= age),
PRESCHOOLER(350, 0.5, (age) -> 6 <= age),
BABIES(0, 1, (age) -> 6 > age);
private final int deductionFare;
private final double discountRate;
private final IntPredicate predicate;
DiscountRate(int deductionFare, double discountRate, IntPredicate predicate) {
this.deductionFare = deductionFare;
this.discountRate = discountRate;
this.predicate = predicate;
}
public static DiscountRate compareAgeBaseline(int age) {
return Arrays.stream(values())
.filter(discountRate -> discountRate.predicate.test(age))
.findFirst()
.orElseThrow(() -> new NoSuchElementException("해당하는 DiscountRate 객체가 없습니다."));
}
public int calculateFare(int currentFare) {
return (int) ((currentFare - deductionFare) * (1 - discountRate));
}
}
기존의 ageBaseline은 지우고, 대신 IntPredicate를 정의하였습니다. 그리고 Predicate의 test() 메소드를 활용하여 조건식에 해당 인자를 넣으면 참인지 확인하여 참인 DiscountRate만 걸러냅니다. 마지막으로, 걸러낸 DiscountRate 중에 첫 번째 요소를 반환하면 되는 것이죠. 이를 통해 compareAgeBaseline()은 새로운 할인 정책이 생겨도 코드 하나 건들 것 없이 단지 열거형 상수만 추가하면 된다는 장점이 있습니다.
정리
우리는 변화의 세상에서 살고 있습니다. 그만큼 소프트웨어에게 정적인 것은 존재하지 않고, 대부분 변화합니다. 이러한 환경에서 우리 개발자들은 변화에 유연한 소프트웨어를 만들어야 하는데, 그러한 목적을 달성하기 위해 가장 많이 사용되는 원칙이 바로 '다형성'입니다. 다형성은 위의 사례에서 보았듯, 반복된 코드를 줄이며 꼭 필요한 코드만 수정한다는 장점이 있습니다. 따라서, 이러한 다형성을 잘 사용하는 것이 좋은 객체 지향 설계의 지름길이라고 생각합니다.
참고 자료
'개발 이야기 > OOP' 카테고리의 다른 글
[OOP] 상속(Inheritance)이란? (2) | 2021.05.31 |
---|---|
[OOP] 캡슐화(Encapsulation)란? (0) | 2021.05.29 |
[SOLID] 의존 역전 원칙(DIP)이란? (3) | 2021.03.12 |
[SOLID] 인터페이스 분리 원칙(ISP)이란? (1) | 2021.03.11 |
[SOLID] 리스코프 치환 원칙(LSP)이란? (2) | 2021.03.11 |
댓글