
흔히들 상속과 다형성을 묶어서 부르곤하는데 나는 그 두 개 중에서 상속보다는 다형성이 좀 더 어려웠다. 물론 이게 바로 OOP의 진짜 꽃
다형성(polymorphism)
다형성은 특정 타입의 레퍼런스 변수가 여러 타입을 가질 수 있는 성질을 뜻한다. 즉, 상위클래스의 참조변수에 하위클래스의 타입을 적용시킬 수 있다는 뜻이다.
달리말하면 상위클래스의 참조변수에 하위클래스의 인스턴스를 대입할 수 있다. 반대는 안된다.
package hongikUniv;
class Member {
int age;
String name;
public Member() {
age = 30;
name = "최원준";
}
public Member(int age, String name) {
this.age = age;
this.name = name;
}
}
class Student extends Member {
int grade;
public Student() {
super();
grade = 4;
}
public Student(int age, String name) {
super(age, name);
grade = 4;
}
public Student(int age, String name, int grade) {
super(age, name);
this.grade = grade;
}
}
class Programmer extends Member{
String language;
public Programmer() {
super();
language = "java";
}
public Programmer(int age, String name) {
super(age, name);
language = "java";
}
public Programmer(int age, String name, String language) {
super(age, name);
this.language = language;
}
}
이런 클래스들이 있다고하자
public static void main(String[] args) {
Programmer p = new Programmer();
Member m = new Programmer();
System.out.println(p.name + p.age + p.language);
System.out.println(m.name + m.age);
}
즉, 이렇게 좌항과 우항의 타입이 같지 않더라도 서로 상속관계에 있을 경우 상위클래스의 레퍼런스 변수로 하위클래스의 객체를 주어도된다. 위 코드에서는 p와 m 모두 30세 최원준 객체가 형성된다.
등호 오른쪽에 생성자가 호출된 클래스가 실제 인스턴스가 되고, 등호 왼쪽의 타입이 참조변수의 타입이다.
실제 객체는 프로그래머 객체이지만, 참조변수는 Member 객체를 기준으로 적용된다.
따라서 다형성을 활용할 경우 하위클래스에서는 상위클래스의 멤버만 사용가능하다.
위의 예시에서 프로그래머 객체를 넣은 변수 p는 자바를 다루는 30세 최원준 객체가, 멤버 변수 m은 그냥 30세 최원준 객체가 형성된다. 여기서 프로그래머 객체의 단독 인스턴스 변수인 language는 m에 적용되지 않는다. 모두 같은 타입의 인스턴스지만 사용 가능한 멤버의 개수가 달라진다.
Programmer p1 = new Member();
반대과정은 불가능하다. 실제 인스턴스인 Member에서 사용 가능한 멤버의 개수보다 참조변수 p1에서 사용가능한 변수가 더 많기 때문에 불가능하다. p1에서 language를 달라고하면 Member는 그게 무엇인지 모르기 때문이다.
참조변수의 타입이 참조하고 있는 인스턴스에서 사용할 멤버의 수를 정한다. 참조변수의 타입이 상위클래스, 인스턴스의 타입이 하위클래스인 경우에는 하위클래스에서 사용할 수 있는 멤버는 상위클래스에서 상속받은 것으로 제한된다.
형변환
어.. 형변환을 하기 전에 약간 명확하게 분리를 해야할 게 있을 것 같다.
Member member = new Programmer();
여기서 참조변수로 member가선언되어 있고, member의 변수타입은 Member다.
하지만 member 변수의 실제 인스턴스의 타입은 Programmer 이다. 형변환은 참조변수의 타입을 변경하는 것이며, 실제 인스턴스의 타입은 변경되지 않는다.
참조형 변수 간 형변환도 두 클래스가 상속관계라는 가정 하에 가능하다. 이 때 하위클래스의 참조변수를 상위클래스로 형변환하는 과정에서는 생략이 가능하다. 즉, 상위클래스 = 하위클래스의 식처럼 하위클래스를 상위클래스로 변경시키는 것은 가능하다.

이렇게 상위클래스인 Member 인스턴스 변수 m, 하위클래스인 Programmer 인스턴스 변수 p를 선언한 후,
m = p; 식을 실행하면 Programmer에서 Member로 자동으로 형변환이 일어난다. 따라서 타입도 변하게되고 내용도 변하게되고
하위클래스 → 상위클래스: 자동으로 가능 (Up-casting)
Member member = new Programmer();
Member member = (member) new Programmer();
사실 이런 식으로 생성자를 통해 다형성을 적용할 경우에도 자동 형변환이 일어난다.
상위클래스 → 하위클래스 : 자동으로 불가 (Down-casting / 반드시 캐스팅해야함)
이러한 이유의 원인은 참조변수는 참조변수의 타입에서 메서드나 인스턴스 변수를 찾는다.
따라서 업캐스팅을 진행하게되면 어차피 참조변수의 멤버 수와 실제 인스턴스 객체의 멤버 수는 동일하거나 작을 것이 자명하다. 위의 예시를 들면 Member 객체의 멤버의 수가 Member를 확장한 Programmer 객체의 멤버의 수보다 작다.
그렇게 되면 참조변수의 타입인 Member 객체의 멤버는 모두 실제 인스턴스 타입인 Programmer가 가지고 있기 때문에 문제될 것이 없어 형변환 생략이 가능하다. 또 참조변수에서 인스턴스 변수와 메서드를 찾기 때문에 하위클래스가 단독으로 가지고 있는 멤버는 사용이 불가능한 것이고
반면 다운캐스팅의 경우에는 참조변수의 멤버 수가 실제 인스턴스 객체의 멤버 수보다 많다. 아래의 예시에서
Prgrammer p1 = new Member();
이렇게 다운캐스팅을 진행하게되면 p1 변수는 참조변수인 Programmer 클래스에서 멤버를 찾게되는데, 만약 Programmer 객체가 단독으로 가지는 멤버가 있다면 이는 실제 인스턴스 타입인 Member 객체에는 존재하지 않고, 결국 오류가 생길 것이다. 그렇기 때문에 다운캐스팅의 경우에는 자동으로 형변환이 일어나지 않는다.
이러한 형변환은 인스턴스를 변환하는 것이 아니며, 참조 변수의 타입을 해석하는 방법을 변환하는 것이다. 그렇기 때문에 참조변수의 변환을 통하여 인스턴스에서 사용가능한 멤버의 수를 조절하는 것이다. 업캐스팅을 통하면 다룰 수 있는 멤버의 수가 감소하고, 다운캐스팅을 통하면 다룰 수 있는 멤버의 수가 증가한다.
다운캐스팅의 조건
동일한 예시에서
Member member1 = new Member();
Member member2 = null;
Programmer programmer1 = null;
programmer1 = (Programmer) member1;
System.out.println("member1 다운캐스팅 성공");
programmer1 = (Programmer) member2;
System.out.println("member2 다운캐스팅 성공");
이런 코드가 실행된다고 할 때, 컴파일은 잘 되지만 런타임에러인 ClassCastException 에러가 발생한다.
캐스팅을 사용하여 상위클래스의 참조변수 (여기서는 member1)를 하위클래스의 참조변수로 형변환을 한 것이지만, 자바세계에서는 상위클래스의 인스턴스 변수가 하위클래스 타입의 참조변수로 참조하는 것은 허용되지 않는다.
즉, 형변환 시에는 인스턴스 자체가 변하는 것이 아니라 참조변수가 변하는 것이기에, 실제 Member 클래스를 인스턴스로 가지는 참조변수 member1의 타입은 실제 인스턴스의 하위클래스로 형변환 할 수 없다. 왜냐하면 다운캐스팅을 반드시 명시적으로 진행해야하는 이유와 동일하다. 참조변수의 멤버가 실제 인스턴스에 없을 수 있으니까
Member member1 = new Programmer();
Member member2 = null;
Programmer programmer1 = null;
programmer1 = (Programmer) member1;
System.out.println("member1 다운캐스팅 성공");
programmer1 = (Programmer) member2;
System.out.println("member2 다운캐스팅 성공");
이렇게하면 형변환을 진행해도 Programmer 인스턴스 변수를 Programmer 타입의 참조변수에 대입하는 것이므로 가능하다.
타입 알아보기 - instanceof
그렇다면 실제 인스턴스의 타입은 어떻게 알아볼 수 있는가? instanceof 연산자를 사용해야한다. instanceof를 기준으로 좌측에 있는 참조변수와 우측에 있는 타입 간 비교가 이루어진다. 같으면 true, 아니면 false를 반환한다. 참조변수의 값이 null이면 반드시 false를 반환한다.
Member member1 = new Member();
if (member1 instanceof Student) {
Student student1 = (Student)member1;
System.out.println(student1.age + student1.name + student1.grade);
} else if (member1 instanceof Programmer) {
Programmer programmer1 = (Programmer)member1;
System.out.println(programmer1.age + programmer1.name + programmer1.language);
} else {
System.out.println(member1.age + member1.name);
}
이렇게 instacneof와 조건문을 이용하면 타입을 조사하고 맞는 타입에 따라서 다운캐스팅을 진행한다. 형변환을 하는 이유인 접근가능한 멤버수의 조절이 이렇게 진행되며, 형변환을 통해 하위클래스에서 상속받지 않고 고유하게 가지고 있는 멤버에 접근가능하다.

인스턴스의 생성을 기본생성자가 담당하고 있기 때문에 Member 객체를 생성하여 조건문에 넣으면 else 부분에 걸려서 30세 최원준 객체가 반환된다

객체가 Student라면 조건문에서 Student 조건에 걸리고 다운캐스팅도 진행된 다음에 4학년 30세 최원준 객체를 반환한다.

객체가 Programmer로 선언되면 조건문에 걸려서 자바가 가능한 30세 최원준 객체가 반환된다.
System.out.println(member1.getClass().getName());
System.out.println(member1 instanceof Object);
마지막에 두 줄의 코드를 추가했더니

실제 클래스 이름은 hongikUniv.Programmer (패키지명이 hongikUniv)고 Object 클래스와 instanceof 연산을 수행했더니 true가 반환되었다. 즉, instanceof 연산자를 사용하면 상위클래스와도 연산을 진행하면 true가 반환된다. 또한 true가 반환되었다는 뜻은 해당타입으로 형변환을 진행해도 문제될 것이 없다는 뜻이다.
Member member1 = new Member();
Member member2 = null;
Programmer programmer1 = null;
if (programmer1 instanceof Member) {
programmer1 = (Programmer) member1;
System.out.println("member1 다운캐스팅 성공");
} else {
System.out.println("캐스팅 불가!");
}
아까 다운캐스팅에서 문제가 되는 예시를 가져오면

이렇게 캐스팅이 instanceof 연산자에서 false를 반환하게되므로 캐스팅이 불가능하다.
if (member1 instanceof Student student1) {
System.out.println(student1.age + student1.name + student1.grade);
} else if (member1 instanceof Programmer programmer1) {
System.out.println(programmer1.age + programmer1.name + programmer1.language);
} else {
System.out.println(member1.age + member1.name);
}
이렇게 조건문에서 지역변수를 선언하여 사용할 수도 있다. 다만 지역변수기 때문에 조건문 외에서는 사용할 수 없다. 여기서 Student student1 같은 변수는 instanceof 연산이 true일 경우에만 주어진 변수를 캐스팅한 임시로 사용하는 참조변수이다. false일 경우 변수가 선언되지 않는다.
따라서
if (member1 instanceof Student student1 && student1 != null) {
System.out.println(student1.age + student1.name + student1.grade);
}
또 && 연산자와 instanceof 연산자를 결합할 수도 있다. 다만 || 연산자와는 결합할 수 없다. || 연산자는 왼쪽 피연산자가 false여도 오른쪽 피연산자를 검사하기 때문이다. 만약 instanceof 연산이 false로 반환되어 변수가 선언되지 않은 경우 오른쪽 피연산자에 사용할 변수를 알 수 없기 때문이다.
Member member1 = new Student();
if (member1 instanceof Student student1 || student1 != null) {
System.out.println(student1.age + student1.name + student1.grade);

이렇게..
switch 이용하기
복잡한 if문을 switch문을 통해 더 간결하게 줄일 수도 있다.
다만 switch의 조건식에 타입값이 들어오는 기능은 JDK21부터 지원한다.
switch(member1) {
case Student st -> System.out.println(st.age + st.name + st.grade);
case Programmer p -> System.out.println(p.age + p.name + p.language);
case Member m -> System.out.println(m.age + m.name);
default -> System.out.println("no!");
}

그래서 JDK17에서는 preview feature로 기본적으로는 막혀있기 때문에 이걸 풀어야하는데 방법을 좀 찾아보려고한다.
암튼 주의사항으로는
switch 식은 가능한 모든 경우의 수를 처리해야한다. 아니면 에러가 터진다. 그렇기 때문에 클래스는 무한히 확장 가능하므로 default문이 필수적으로 필요한 것. 다만 참조변수의 값이 null일 경우에는 NPE가 터지므로 따로 switch문에 null을 추가하여 에러가 터지지 않도록 막아야한다. switch식에서 조건식에 null이 들어올 수 있도록하는 기능은 JDK21부터 추가되었다. 나도 갈아탈까 21로
이건 C프 때도 한 것 같은데 switch문은 순서가 중요하다. 상위클래스의 조건식이 먼저 올 경우 상위클래스와 그의 하위클래스까지 전부 조건식을 처리한 다음에 그 밑에 있는 하위클래스의 조건식은 처리하지 않는다. 따라서 하위클래스가 먼저 switch문에 조건식으로 와야한다.
참조변수와 인스턴스
상위클래스의 인스턴스와 하위클래스에 인스턴스가 같은 이름으로 선언된다면 어떻게 해야할까?
우선 상위클래스의 참조변수로 하위클래스의 인스턴스를 호출하는 것과 하위클래스의 참조변수로 하위클래스의 인스턴스를 호출하는 것은 결과가 드랃. 다만 메서드의 경우에는 무조건 실제 인스턴스의 메서드가 호출된다. 정적변수와 정적메서드의 경우에는 참조변수의 타입에 영향을 받는다. 그렇기 때문에 반드시 정적멤버는 클래스이름.메서드()로 호출해야한다.
인스턴스 변수의 경우에는 참조변수에 의존한다.
class Member {
int age;
String name;
public Member() {
age = 30;
name = "최원준";
}
public Member(int age, String name) {
this.age = age;
this.name = name;
}
public void Print(){
System.out.println("Member!");
}
}
class Student extends Member {
int grade;
int age;
public Student() {
super();
age = 33;
grade = 4;
}
public Student(int age, String name) {
super(age, name);
grade = 4;
}
public Student(int age, String name, int grade) {
super(age, name);
this.grade = grade;
}
@Override
public void Print() {
System.out.println("Student!");
}
}
계속 써오던 예시를 조금 수정해서 하위클래스인 Student에 상위클래스인 Member와 이름이 동일한 인스턴스 변수 age를 선언했다.
public static void main(String[] args) {
Member member1 = new Student();
Student member2 = new Student();
System.out.println(member1.age);
System.out.println(member2.age);
}

분명 같은 인스턴스의 같은 인스턴스 변수를 호출했지만 참조변수에 의해 다른 값이 불러와지는 것을 알 수 있다.
Member member1 = new Student();
Student member2 = new Student();
member1.Print();
member2.Print();

메서드의 경우에는 인스턴스 변수에 의존한다.
클래스 내부에서는 this로 자신의 인스턴스 변수를, super로 상위클래스의 인스턴스 변수를 구분해야한다.
물론 기본적으로는 캡슐화를 통해서 변수에 접근해야하므로 이렇게 구분을 명확하게 해야할 일이 적긴하지만, 변수에 직접적으로 접근해야할 때는 이 점을 알아두어야한다.
매개변수의 다형성
매개변수에도 다형성이 적용될 수 있다. 매개변수로 상위클래스를 넘겨주면, 그의 하위클래스들이 매개변수로 넘겨져도 그 클래스의 메서드와 인스턴스 변수들이 자동적으로 사용된다. 왜냐하면 그 하위클래스가 상속받은 멤버들은 당연히 매개변수로 넘겨지는 상위클래스에도 있을 것이므로 하위클래스가 매개변수로 들어와도 실행에 문제가 없기 때문이다.
또한 배열에도 다형성이 적용된다 Member 객체배열을 선언하고 그 안에 Student도 넣어도되고, Programmer 객체를 넣어도 된다. 다만 크기 조절도 해야하는데, 이럴 때는 Vector 클래스를 사용하면 편하다.
Vector 클래스는 동적크기조절이 가능한 배열이다. Vector 클래스를 사용하면 Object 객체배열에 값이 저장되며, get() 메서드를 통해 값을 가져오는 것도 자유롭게 가능하다. 단, 배열에는 Object 클래스가 저장되므로 원하는 형태로 형변환을 해야한다.
이렇게 다형성에 대해서 다뤄봤다.
내용도 길고, 어렵기도하고, 초반에 참조변수의 타입과 실제 인스턴스의 타입의 개념을 확립하느라 좀 애먹었던 것도 있다. 헤드퍼스트자바가 진짜 얕게만 건드린거구나.. 싶다. 다형성을 통한 변수 선언 시의 형변환, 형변환할 때의 주의점, 안되는점 등... 상속보다 어렵다 역시 당연한 얘기지만
근데 아직 안끝났다. 내부클래스도 해야하고.. 추상클래스도 해야하고.. 인터페이스도 해야하고.. 할 일이 참 많네요. 이래야 7장을 다 읽는다. 이펙티브자바 언제읽지 BE 파트멤버 세미나도 준비해야하고 시험공부도해야하는데 할일이 참 많아요
'언어공부 > Java | Kotlin' 카테고리의 다른 글
| [CS] 자바의 정석 독서 #13 - 내부클래스 (0) | 2025.10.10 |
|---|---|
| [CS] 자바의 정석 독서 #12 - 추상클래스와 인터페이스 (0) | 2025.10.09 |
| [CS] 자바의 정석 독서 #10 - 패키지와 접근제어자 (0) | 2025.10.02 |
| [CS] 자바의 정석 독서 #9 - 상속 (0) | 2025.10.02 |
| [CS] 자바의 정석 독서 #8 - 생성자와 초기화 (0) | 2025.10.01 |