다형성에 대하여
Poly는 “많은, 다양한”이라는 의미고, morph는 “변하다”라는 뜻이다. 즉, 다형성이란 다양한 형태로 변할 수 있는 것을 의미한다. 객체 지향 프로그래밍(OOP)에서 다형성은 개체가 다양한 형태로 변할 수 있는 능력을 말하며, 이를 OOP의 핵심이라고 여기는 사람도 많다. 같은 함수를 호출해도 각 객체의 종류에 따라 다른 동작을 하는 것이 다형성의 본질이다.
같은 함수 시그내처를 호출하지만, 실제로 어떤 함수 구현이 실행될지는 실행 중에 결정된다. 이를 늦은 바인딩(late binding)이라고 부르며, 다형성 덕분에 실행 중에 함수가 동적으로 연결되는 방식이다. 반면, 일반적인 함수 호출 방식은 이른 바인딩(early binding)으로, 컴파일 중에 어떤 함수가 호출될지 이미 결정된다.
다형성과 상속
다형성의 이점을 누리기 위해서는 상속 관계가 필요하다. 부모 객체에서 함수 시그내처를 정의하고, 자식 객체가 이를 오버라이딩(overriding)하여 각기 다른 방식으로 구현하는 것이다. 이 방식으로 다양한 개체를 같은 타입으로 저장할 수 있어, 코드를 효율적으로 관리할 수 있다.
예를 들어, 부모 타입의 배열에 자식 객체를 저장해두고 for문을 통해 배열을 순회하면서 동일한 함수를 호출하면, 각 객체가 자기 나름대로 동작을 수행한다. 이런 구조 덕분에 코드가 훨씬 깔끔해지고 유지보수가 쉬워진다.
public class TestArrayPolymorphism {
public static void main(String[] args) {
Animal[] animals = {new Dog(), new Cat()};
for (Animal animal : animals) {
animal.sound(); // Dog barks, Cat meows
}
}
}
다형성의 구현
다형성은 주로 두 가지 방식으로 구현된다: **오버로딩(Overloading)**과 **오버라이딩(Overriding)**이다. 이 두 개념은 각각 다른 상황에서 다형성을 활용할 수 있게 해준다.
오버로딩(Overloading)
오버로딩은 같은 이름의 메서드를 여러 개 정의하되, 매개변수의 타입이나 개수, 순서를 다르게 하여 메서드를 구분하는 방식이다. 이를 통해 동일한 기능을 가진 메서드를 여러 가지 형태로 제공할 수 있다.
예를 들어, 아래의 add
메서드는 서로 다른 매개변수 타입을 받아 다양한 형태로 동작할 수 있다.
class MathUtils {
// 두 개의 정수를 더하는 메서드
int add(int a, int b) {
return a + b;
}
// 세 개의 정수를 더하는 메서드
int add(int a, int b, int c) {
return a + b + c;
}
// 두 개의 실수를 더하는 메서드
double add(double a, double b) {
return a + b;
}
}
public class TestOverloading {
public static void main(String[] args) {
MathUtils math = new MathUtils();
System.out.println(math.add(2, 3)); // 5
System.out.println(math.add(1, 2, 3)); // 6
System.out.println(math.add(2.5, 3.5)); // 6.0
}
}
위의 예제에서 add
메서드는 매개변수의 개수와 타입에 따라 서로 다른 기능을 수행한다. 이처럼 오버로딩을 통해 코드의 가독성을 높일 수 있다.
오버라이딩(Overriding)
오버라이딩은 부모 클래스에서 정의된 메서드를 자식 클래스에서 다시 정의하는 것을 의미한다. 자식 클래스는 부모 클래스의 메서드와 동일한 이름과 매개변수를 가진 메서드를 정의함으로써, 부모 클래스의 메서드를 재정의하여 새로운 동작을 구현할 수 있다.
아래의 예제에서는 Animal
클래스와 그 자식 클래스인 Dog
와 Cat
에서 sound
메서드를 오버라이딩하였다.
class Animal {
void sound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("Dog barks");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("Cat meows");
}
}
public class TestOverriding {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
myDog.sound(); // Dog barks
myCat.sound(); // Cat meows
}
}
늦은 바인딩과 이른 바인딩
자바에서 다형성은 기본적으로 늦은 바인딩을 통해 동작한다. 실행 중에 실제로 호출되는 메서드가 결정되며, 이를 동적 바인딩(dynamic binding)이라고도 한다. 반면, 이른 바인딩은 컴파일 중에 호출할 함수가 결정되며, 정적 바인딩(static binding)이라고 부른다.
C 언어에서는 함수 호출 시 이른 바인딩이 기본이다. 컴파일러는 함수 호출문을 직접 jmp 명령어로 변환하고, 해당 함수의 메모리 주소로 점프한다.
#include <stdio.h>
void greet() {
printf("Hello, World!\n");
}
int main() {
greet(); // 이른 바인딩: 컴파일 시점에 함수가 결정됨
return 0;
}
반면, 자바는 다형성을 지원하기 때문에 기본적으로 늦은 바인딩을 사용한다. 하지만, 자바에서도 이른 바인딩이 가능하다. 메서드나 클래스에 final
키워드를 붙이면 해당 메서드는 오버라이딩할 수 없게 되고, 이른 바인딩 방식으로 처리된다.
class Example {
final void display() {
System.out.println("This is a final method.");
}
}
이른 바인딩의 장점
이른 바인딩은 컴파일러가 어떤 함수가 호출될지 미리 알기 때문에, 최적화가 더 잘될 수 있다. 함수 호출 시점에 불필요한 작업을 줄이고, 성능을 높일 수 있다. 반면, 늦은 바인딩은 실행 중에 결정되므로 성능상 약간의 불이익이 있을 수 있다. 하지만, 자바의 다형성을 활용하면 코드의 유연성과 확장성이 크게 증가한다.
final 키워드의 활용
final
키워드는 이른 바인딩을 가능하게 한다. 메서드 앞에 final
을 붙이면 자식 클래스에서 오버라이딩할 수 없고, 이는 C 언어의 함수 호출 방식과 동일하게 동작한다. 또한, 클래스나 변수 앞에 final
을 붙이면 해당 클래스를 상속하거나 변수를 변경할 수 없게 된다.
최선의 방법은 가능하면 변수, 메서드, 클래스에 final
을 붙이는 것이다. 이후에 변경이나 상속이 필요할 때 final
을 빼도 상관없지만, 기본적으로는 final
을 적용하는 것이 안전한 설계다.
final class FinalClass {
final void finalMethod() {
System.out.println("This is a final method.");
}
}
// 아래 코드는 컴파일 오류가 발생한다.
// class SubClass extends FinalClass {}
// class AnotherClass extends SubClass {
// void finalMethod() {} // 이것도 컴파일 타임에 오류가 발생한다.
// }
다형적 메서드와 Object 클래스
자바의 모든 클래스는 Object
클래스를 상속받는다. 따라서 Object
에 정의된 메서드들은 모든 클래스에서 사용할 수 있으며, 필요에 따라 오버라이딩도 가능하다. 대표적인 예로 toString()
, equals()
, hashCode()
메서드가 있다.
toString()
메서드는 객체를 사람이 읽기 쉬운 문자열로 변환한다. 기본적으로는 클래스 이름과 객체의 해시코드를 반환하지만, 이를 오버라이딩하면 각 객체의 상태를 보기 좋게 표현할 수 있다.equals()
메서드는 두 객체가 같은지를 비교하는 메서드로, 기본적으로는 메모리 주소를 비교한다. 하지만, 의미 있는 비교를 위해 객체의 상태를 비교하도록 오버라이딩할 수 있다.hashCode()
메서드는 객체의 해시 값을 반환하는 메서드로, 주로HashMap
과 같은 컬렉션에서 사용된다. 객체가 동일하면 해시값도 동일하게 만들어야 하며, 그렇지 않으면HashMap
에서 제대로 동작하지 않는다.
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Person)) return false;
Person other = (Person) obj;
return name.equals(other.name) && age == other.age;
}
@Override
public int hashCode() {
return name.hashCode() + age;
}
}
public class TestObjectMethods {
public static void main(String[] args) {
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
System.out.println(p1.toString()); // Person{name='Alice', age=30}
System.out.println(p1.equals(p2)); // true
System.out.println(p1.hashCode() == p2.hashCode()); // true
}
}
출처
- 개체지향 프로그래밍 및 설계 - 유데미 강의
- 다형성이란?