❗ 다형성이란 프로그램 언어 각 요소들(상수, 변수, 식, 객체, 메소드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킵니다.
또는 여러 형태를 받아들일 수 있는 성질, 상황에 따라 의미를 다르게 부여할 수 있는 특성 등으로 정의를 하기도 한다. 정리하면 다형성이란 하나의 타입에 여러 객체를 대입할 수 있는 성질로 이해하면 될 것이다.
✅ 하나의 코드가 여러 자료형으로 구현되어 실행
✅ 같은 코드에서 여러 다른 실행 결과가 나옴
✅ 정보은닉, 상속과 더불어 객체지향 프로그래밍의 가장 큰 특징 중 하나
✅ 다형성을 잘 활용하면 유연하고 확장성 있고, 유지보수가 편리한 프로그램을 만들 수 있다.
✅ 상위 클래스에서는 공통적인 부분을 제공하고 하위 클래스에서는 각 클래스에 맞는 기능 구현
✅ 여러 클래스를 하나의 타입(상위 클래스)으로 핸들링 할 수 있음
✅ 상속과 메서드 재정의를 활용하여 확장성 있는 프로그램을 만들 수 있다.
메소드 오버로딩을 예시로 들어보자. 자바의 PrintStream.class에 정의되어 있는 println이라는 함수는 다음과 같이 매개변수지만 다른 여러 개의 메소드가 정의되어 있습니다.
매개변수로 배열을 넣을 때, 문자열을 넣을 때, 그리고 객체를 넣을 때 모두 println
이라는 메소드 시그니처를 호출하여 원하는 내용을 출력하는 기능을 수행합니다.
public class PrintStream {
...
public void println() {
this.newLine();
}
public void println(boolean x) {
synchronized(this) {
this.print(x);
this.newLine();
}
}
public void println(char x) {
synchronized(this) {
this.print(x);
this.newLine();
}
}
public void println(int x) {
synchronized(this) {
this.print(x);
this.newLine();
}
}
...
}
오버로딩은 여러 종류의 타입을 받아들여 결국엔 같은 기능을 하도록 만들기 위한 작업입니다. 이 역시 메소드를 동적으로 호출할 수 있으니 다형성이라고 할 수 있습니다. 하지만 메소드를 오버로딩 하는 경우 요구사항이 변경되었을 때 모든 메소드에서 수정이 수반되므로 필요한 경우에만 적절히 고려하여 사용하는 것이 좋을 듯 하다.
오버로딩과 이름이 비슷해 헷갈려하는 개발자들도 있을 것이다. 오버라이딩은 상위 클래스의 메소드를 하위 클래스에서 재정의하는 것을 말합니다. 따라서 여기서는 상속의 개념이 추가됩니다. 아래 예시로 보인 추상 클래스 Figure에는 하위 클래스에서 오버라이드 해야 할 메소드가 정의되어 있습니다.
// Figure.class
public abstract class Figure {
protected int dot;
protected int area;
public Figure(final int dot, final int area) {
this.dot = dot;
this.area = area;
}
public abstract void display();
// getter
}
Figure을 상속받은 하위 클래스인 Trangle 객체는 해당 객체에 맞는 기능을 구현합니다.
// Trangle.class
public class Triangle extends Figure {
public Triangle(final int dot, final int area) {
super(dot, area);
}
@Override
public void display() {
System.out.printf("넓이가 %d인 삼각형입니다.", area);
}
}
만약 사각형 객체를 추가하고 싶다면, 같은 방식으로 Figure을 상속받되 메소드 부분에서 사각형에 맞는 display메소드를 구현해주면 됩니다. 이렇게 하면 추후 도형 객체가 추가되더라도 도형 객체가 실제로 사용되는 비즈니스 로직의 변경을 최소화할 수 있습니다.
public static void main(String[] args) {
Figure figure = new Triangle(3, 10); // 도형 객체 추가 또는 변경 시 이 부분만 수정
for (int i = 0; i < figure.getDot(); i++) {
figure.display();
}
}
만약 여기서 다형성을 사용하지 않고 도형 객체를 추가하는 로직을 생각해 본다면 아마 다음과 같이 if-else 분기가 늘어나게 될 것입니다.
public static void main1(String[] args) {
int dot = SCANNER.nextInt();
if (dot == 3) {
Triangle triangle = new Triangle(3, 10);
for (int i = 0; i < triangle.getDot(); i++) {
triangle.display();
}
} else if(dot == 4) {
Rectangle rectangle = new Rectangle(4, 20);
for (int i = 0; i < rectangle.getDot(); i++) {
rectangle.display();
}
}
....
}
도형이 2개 밖에 없는데 코드 양의 차이가 엄청나다.
여기까지 오버라이드 방식으로 다형성을 구현하는 방법을 살펴보았다. 예시에서는 추상클래스를 사용했지만, 인터페이스도 구현의 정도만 차이가 있을 뿐 같은 사용 방식은 같다. 오버라이드 다형성 방식을 잘 활용하면, 기능의 확장과 객체의 수정에 유연한 구조를 가져갈 수 있다.
마지막으로는 함수형 인터페이스 방식을 살펴보자. 함수형 인터페이스(Functional Inteface)란, 람다식을 사용하기 위한 API로 자바에서 제공하는 인터페이스에 구현할 메소드가 하나 뿐인 인터페이스를 의미한다. 함수형 인터페이스는 enum과 함께 사용한다면 다형성의 장점을 경험할 수 있다.
가장 간단한 예시로 문자열 계산기 예시로 들어보겠습니다.
public enum Operator {
PLUS("+", (a, b) -> a + b),
MINUS("-", (a, b) -> a - b),
MULTIPLY("*", (a, b) -> a * b),
DIVIDE("/", (a, b) -> a / b);
private final String sign;
private final BiFunction<Long, Long, Long> bi;
Operator(String sign, BiFunction<Long, Long, Long> bi) {
this.sign = sign;
this.bi = bi;
}
public static long calculate(long a, long b, String sign) {
Operator operator = Arrays.stream(values())
.filter(v -> v.sign.equals(sign))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
return operator.bi.apply(a, b);
}
}
사칙연산을 할 수 있는 각각의 연산자를 enum으로 미리 정의하고 연산 방식을 BiFunction을 사용한 람다식으로 정의할 수 있다. 이때 연산자를 추가해야할 경우 enum에 추가하기만 하면, 실질적인 연산을 수행하는 calculate 메소드는 아무런 수정없이도 기능을 확장할 수 있다.
⚡ 형변환(Casting) ⚡
상속받은 객체에 대해서 형변환이 의미하는 것은, 객체에 속한 멤버들에 대한 사용범위가 달라진다는 것을 의미합니다.
- 업캐스팅(upcasting): (자식클래스의 인스턴스에 대한) 자식클래스의 타입의 레퍼런스 변수를 부모클래스 타입으로 형변환 하는 것. (타입변환 구문 생략 가능, 자동 형변환 된다.)
- 다운캐스팅(downcasting): (자식클래스의 인스턴스에 대한) 부모클래스 타입의 레퍼런스 변수를 자식클래스 타입으로 형변환 하는 것. (타입변환 구문 생략 불가, 형변환 타입을 명시해야 합니다.)
장점
- 여러 자식클래스 객체를 하나의 배열로 다룰 수 있습니다.
- 메소드의 매개변수를 부모클래스 타입 하나로 전달받아 사용할 수 있습니다.
Reference