주제 : 김영한님의 자바 실전 강의 총 정리
내용 : 다형성에 대해 공부!
다형성은 이름 그대로 "다양한 형태", "여러 형태"를 뜻한다.
프로그래밍에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 뜻한다. 보통 하나의 객체는 하나의 타입으로 고정되어 있다. 그런데 다형성을 사용하면 하나의 객체가 다른 타입으로 사용될 수 있다는 뜻이다.
다형성을 이해하기 위해서는 크게 2가지 핵심 이론을 알아야 한다.
- 다형적 참조 : 부모는 자식을 품을 수 있다.
- 메서드 오버라이딩
public class PolyMain { public static void main(String[] args) { // 부모 변수가 부모 인스턴스 참조 System.out.println("Parent->Parent"); Parent parent = new Parent(); parent.parentMethod(); // 자식 변수가 자식 인스턴스 참조 System.out.println("Child->Child"); Child child = new Child(); child.parentMethod(); // 자식 클래스의 인스턴스가 부모 클래스의 메서드를 호출 child.childMethod(); //부모 변수가 자식 인스턴스 참조(다형적 참조) System.out.println("Parent -> Child"); Parent poly = new Child(); poly.parentMethod(); //Child child1 = new Parent(); 자식은 부모를 담을 수 없다. // 자식의 기능은 호출할 수 없다. 컴파일 오류 발생 // poly.childMehotd(); } }
child.parentMethod();: 상속받은 자식 클래스는 부모 클래스의 메서드들을 포함하므로, 자식 클래스의 인스턴스는 부모 클래스의 메서드를 호출할 수 있습니다.실행 결과
Parent -> Parent Parent.parentMethod Child -> Child Parent.parentMethod Child.childMethod Parent -> Child Parent.parentMethod
다형적 참조 : 부모 타입의 변수가 자식 인스턴스 참조
Parent->Child : poly.parentMethod()
- 부모 타입의 변수가 자식 인스턴스를 참조한다.
- 생성된 참조값을
Parent타입의 변수인poly에 담아둔다.
부모는 자식을 담을 수 있다
Parent poly는 부모 타입이다. new Child()를 통해 생성된 결과는 Child 타입이다. 자바에서 부모 타입은 자식 타입을 담을 수 있다!Parent poly = new Child() : 성공자바에서 부모 타입은 자신은 물론이고, 자신을 기준으로 모든 자식 타입을 참조할 수 있다. 이것이 바로 다양한 형태를 참조할 수 있다고 해서 다형적 참조라 한다.(중요!!)
다형적 참조의 한계
Parent Child: poly.childMethod()
Parent poly = new Child()이렇게 자식을 참조한 상황에서poly가 자식 타입은Child에 있는childMethod()를 호출하면 어떻게 될까?
poly.childMethod()를 실행하면 먼저 참조값을 통해 인스턴스를 찾는다. 그리고 다음으로 인스턴스 안에서 실행할 타입을 찾아야 한다. 그런데, 상속 관계는 부모 방향으로 찾아 올라갈 수는 있지만 자식 방향으로 찾아 내려갈 수 는 없다.
Parent는 부모 타입이고 상위에 부모가 없다. 따러서childMethod()를 찾을 수 없으므로 컴파일 오류가 발생한다.이런 경우
childMethod()를 호출하고 싶다면 어떻게 해야할까?
바로 캐스팅이 필요하다.
Parent poly = new Child() 와 같이 부모 타입의 변수를 사용하게 되면 poly.childMehotd() 와 같이 자식 타입에 있는 기능은 호출할 수 없다. 하지만 캐스팅을 사용하면 가능해진다.
과정
- 1. 자식 인스턴스 참조
- 2. 다운 캐스팅
public class CastingMain1 { public static void main(String[] args) { //부모 변수가 자식 인스턴스 참조(다형적 참조) Parent poly = new Child(); //단 자식의 기능은 호출할 수 없다. 컴파일 오류 발생 //poly.childMethod(); //다운캐스팅(부모 타입 -> 자식 타입) Child child = (Child) poly; child.childMethod(); } }
실행순서
1.Child child= (Child) poly// 다운캐스팅 통해 부모 -> 자식타입으로 변환 후 대입
2.Child child=(Child) x001// 참조값을 읽은 다음 자식 타입으로 지정
3.Child child=x001//최종 결과
캐스팅 용어
"캐스팅"은 영어 단어 "cast"에서 유래되었다. "cast"는 금속이나 다른 물질을 녹여서 특정한 형태나 모양으로 만드는 과정을 의미한다.Child child = (Child) poly경우Parent poly라는 부모 타입을Child라는 자식 타입으로 변경했다.
부모 타입을 자식 타입으로 변경하는 것을 다운캐스팅이라 한다.
반대로, 부모 타입으로 변경하는 것은 업캐스팅이라 한다.
자식 타입의 기능을 사용하려면 다음과 같이 다운캐스팅 결과를 변수에 담아두고 이후에 기능을 사용하면 된다.
Child child = (Child) poly child.childMethod();하지만 다운캐스팅 결과를 변수에 담아두는 과정이 번거롭다.
이런 과정 없이 일시적으로 다운캐스팅을 해서 인스턴스에 있는 하위 클래스의 기능을 바로 호출할 수 있다.public class CastingMain2 { public static void main(String[] args) { Parent poly = new Child(); // 단 자식의 기능은 호출할 수 없다. // poly.childMethod(); ((Child) poly).childMethod(); } }
poly는Parent타입이다. 그런데 이 코드를 실행하면Parent타입을 임시로Child로 변경한다. 그리고 메서드를 호출할 때Child타입에서 찾아서 실행한다.- 정확히는
poly가Child타입으로 바뀌는 것은 아니다.((Child) poly).childMethod() //다운캐스팅을 통해 부모타입을 자식 타입으로 변환 후 기능 호출 ((Child) x001).childMethod() //참조값을 읽은 다음 자식 타입으로 다운캐스팅
- 참고로 캐스팅을 한다고 해서
Parent poly의 타입이 변하는 것은 아니다. 해당 참조값을 꺼내고 꺼낸 참조값이Child타입이 되는 것이다.- 따라서
poly의 타입은Parent로 그대로 유지된다.- 이렇게 일시적 다운캐스팅을 사용하면 별도의 변수 없이 인스턴스의 자식 타입의 기능을 사용할 수 있다.
다운캐스팅과 반대로 현재 타입을 부모 타입으로 변경하는 것을 업캐스팅이라 한다.
public class CastingMain3 { public static void main(String[] args) { Child child = new Child(); Parent parent1 = (Parent) child; //업캐스팅은 생략가능,생략권장 // `Child` 타입을 `Parent` 타입에 대입 중 Parent parent2 = child; //업캐스팅 생략 parent1.parentMethod(); parent2.parentMethod(); } }
다운캐스팅은 잘못하면 심각한 런타임 오류가 발생할 수 있다.
//다운캐스팅을 자동으로 하지 않는 이유 public class CastingMain4 { public static void main(String[] args) { Parent parent1 = new Child(); Child child1 = (Child) parent1; child1.childMethod(); //문제 없음 Parent parent2 = new Parent(); Child child2 = (Child) parent2; //런타임 오류 - ClassCastException child2.childMethod(); //실행 불가 } }
parent1의 경우Child가 있기 때문에 다운캐스팅을 해도 문제가 되지 않는다.
- 예제의
parent2를 다운캐스팅하면ClassCastException이라는 심각한 런타임 오류가 발생한다.- 메모리 상에 자식 타입은 전혀 존재하지 않는다. 따라서,
Child자체가 존재하지 않기 때문에Child자체를 사용할 수 없다.- 런타임 오류는 이름 그대로 프로그램이 실행되고 있는 시점에 발생하는 오류이다. 런타임 오류는 매우 안좋은 오류이다. 고객이 해당 프로그램을 실행하는 도중에 발생하기 때문이다.
업캐스팅이 안전하고 다운캐스팅이 위험한 이유
- 업캐스팅의 경우 객체를 생성하면 해당 타입의 상위 부모 타입은 모두 함께 생성된다! 따라서, 위로만 타입을 변경하는 업캐스팅은 메모리 상에 인스턴스가 모두 존재하기 때문에 항상 안전하다. 따라서 캐스팅을 생략할 수 있다.
- 반면에, 다운캐스팅의 경우 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 왜냐하면 객체를 생성하면 부모 타입은 모두 함께 생성되지만 자식 타입은 생성되지 않는다. 따라서 개발자가 이런 문제를 인지하고 사용해야 한다는 의미로 명시적으로 캐스팅을 해주어야 한다.
다형성에서 참조형 변수는 이름 그대로 다양한 지식을 대상으로 참조할 수 있다. 그런데, 참조하는 대상이 다양하기 때문에 어떤 인스턴스를 참조하고 있는지 확인해야 한다.
Parent parent1 = new Parent() Parent parent2 = new Child()여기서
Parent는 자신과 같은Parent의 인스턴스도 참조할 수 있고, 자식 타입인Child의 인스턴스도 참조할 수 있다. 이때,parent1,parent2변수가 참조하는 인스턴스의 타입을 확인하고 싶다면instanceof키워드를 사용하면 됩니다.public class CastingMain5 { public static void main(String[] args) { Parent parent1 = new Parent(); System.out.println("parent1 호출"); call(parent1); Parent parent2 = new Child(); System.out.println("parent2 호출"); call(parent2); } private static void call(Parent parent) { parent.parentMethod(); if (parent instanceof Child) { System.out.println("Child 인스턴스 맞음"); Child child = (Child) parent; child.childMethod(); } } }
메서드 오버라이딩에서 꼭 기억해야 할 점은 오버라이딩 된 메서드가 항상 우선권을 가진다는 점이다.
Parent,Child모두value라는 같은 멤버 변수를 가지고 있다.
- 멤버 변수는 오버라이딩 되지 않는다.
Parent,Child모두method()라는 같은 메서드를 가지고 있다.Child에서 메서드를 오버라이딩 했다.
- 메서드는 오버라이딩 된다.
public class Parent { public String value = "parent"; public void method() { System.out.println("Parent.method"); } }public class Child extends Parent { public String value = "child"; @Override public void method() { System.out.println("Child.method"); } }
Child에서Parent의method()를 재정의했다.public class OverridingMain { public static void main(String[] args) { //자식 변수가 자식 인스턴스 참조 Child child = new Child(); System.out.println("Child -> Child"); System.out.println("value = " + child.value); child.method(); //부모 변수가 부모 인스턴스 참조 Parent parent = new Parent(); System.out.println("Parent -> Parent"); System.out.println("value = " + parent.value); parent.method(); //부모 변수가 자식 인스턴스 참조(다형적 참조) Parent poly = new Child(); System.out.println("Parent -> Child"); System.out.println("value = " + poly.value); //변수는 오버라이딩X poly.method(); //메서드 오버라이딩! } }
poly변수는Parent타입이다. 따라서poly.value,poly.method()를 호출하면 인스턴스의Parent타입에서 기능을 찾아서 실행한다.
poly.value:Parent타입에 있는value값을 읽는다.poly.method():Parent타입에 있는method()를 실행하려고 한다.- 그런데 하위 타입인
Child.method()가 오버라이딩 되어 있다.- 오버라이딩 된 메서드는 항상 우선권을 가진다.
따라서,Parent.method()가 아니라Child.method()가 실행된다.
public class Animal { public void sound(){ System.out.println("동물 울음 소리"); } } public class Cat extends Animal{ @Override public void sound(){ System.out.println("냐옹"); } } public class Caw extends Animal{ @Override public void sound(){ System.out.println("음메"); } } public class Dog extends Animal{ @Override public void sound(){ System.out.println("멍멍"); } }
public class AnimalPolyMain3 { public static void main(String[] args) { Animal[] animalArr = {new Dog(), new Cat(), new Caw()}; for (Animal animal : animalArr) { soundAnimal(animal); } } //동물이 추가 되어도 변하지 않는 코드 private static void soundAnimal(Animal animal) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); } }위의 코드에는 2가지 문제가 있다.
- Animal 클래스를 생성할 수 있는 문제
- Animal 클래스를 상속 받는 곳에서 sound() 메서드 오버라이딩을 하지 않을 가능성
-> 추상 클래스와 추상 메서드를 사용하면 이런 문제를 한번에 해결할 수 있다.
추상 클래스
동물(Animal)과 같이 부모 클래스는 제공하지만, 실제 생성되면 안되는 클래스를 추상 클래스라 한다. 추상 클래스는 이름 그대로 추상적인 개념을 제공하는 클래스이다. 따라서, 실체인 인스턴스가 존재하지 않는다. 대신에 상속을 목적으로 사용되고, 부모 클래스 역할을 담당한다.
abstract class AbstractAnimal {...}
- 추상 클래스는 클래스를 선언할 때 앞에 추상이라는 의미의 abstract 키워드를 붙여준다.
- 추상 클래스는 기존 클래스와 완전히 같다. 다만, 직접 인스턴스를 생성하지 못하는 제약이 추가된 것이다.
추상 메서드
부모 클래스를 상속 받는 자식 클래스가 반드시 오버라이딩 해야 하는 메서드를 부모 클래스에 정의할 수 있다. 이것을 추상 메서드라 한다. 추상 메서드는 이름 그대로 추상적인 개념을 제공하는 메서드이다. 따라서 실체가 존재하지 않고,메서드 바디가 없다.
public abstract void sound();
- 추상 메서드는 선언할 때 메서드 앞에 추상이라는 의미의
abstract키워드를 붙여준다.
- 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.
- 추상 메서드는 상속 받는 자식 클래스가 반드시 오버라이딩 해서 사용해야 한다.
public abstract class AbstractAnimal { public abstract void sound(); public void move(){ System.out.println("동물이 이동합니다."); } } public class Dog extends AbstractAnimal{ @Override public void sound(){ System.out.println("멍멍"); } } public class Cat extends AbstractAnimal{ @Override public void sound(){ System.out.println("냐옹"); } } public class Caw extends AbstractAnimal{ @Override public void sound(){ System.out.println("음메"); } }
public class AbstractMain { public static void main(String[] args) { //추상클래스 생성 불가 //AbstractAnimal animal = new AbstractAnimal(); Dog dog = new Dog(); Cat cat = new Cat(); Caw caw = new Caw(); cat.sound(); cat.move(); soundAnimal(cat); soundAnimal(dog); soundAnimal(caw); } //동물이 추가 되어도 변하지 않는 코드 private static void soundAnimal(AbstractAnimal animal) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); } }추상 메서드는 반드시 오버라이딩 해야 한다. 만약 자식에서 오버라이딩 메서드를 만들지 않으면 다음과 같이 컴파일 오류가 발생한다.
순수 추상 클래스 : 모든 메서드가 추상 메서드인 추상 클래스
앞서 만든 예제에서 move() 도 추상 메서드로 만들어야 한다고 가정해보자.
이 경우 AbstractAnimal 클래스의 모든 메서드가 추상 메서드가 된다. 이런 클래스를 순수 추상 클래스라 한다.
move() 가 추상 메서드가 되었으니 자식들은 AbstractAnimal 의 모든 기능을 오버라이딩 해야 한다.
순수 추상 클래스
모든 메서드가 추상 메서드인 순수 추상 클래스는 코드를 실행할 바디 부분이 전혀 없다.
public abstract class AbstractAnimal { public abstract void sound(); public abstract void move(); }이러한 순수 추상 클래스는 실행 로직을 전혀 가지고 있지 않다. 단지 다형성을 위한 부모 타입으로써 껍데기 역할만 제공할 뿐이다.
순수 추상 클래스는 다음과 같은 특징을 가진다.
- 인스턴스를 생성할 수 없다.
- 상속시 자식은 모든 메서드를 오버라이딩 해야 한다.
- 주로 다형성을 위해 사용된다.
자바는 순수 추상 클래스를 더 편리하게 사용할 수 있는 인터페이스라는 기능을 제공한다.
순수 추상 클래스
인터페이스는 class 가 아니라 interface 키워드를 사용하면 된다.
public interface InterfaceAnimal { public abstract void sound(); public abstract void move(); }
인터페이스 - public abstract 키워드 생략 가능
public interface InterfaceAnimal { void sound(); void move(); }순수 추상 클래스는 다음과 같은 특징을 가진다.
- 인스턴스를 생성할 수 없다.
- 상속시 모든 메서드를 오버라이딩 해야 한다.
- 주로 다형성을 위해 사용된다.
인터페이스는 앞서 설명한 순수 추상 클래스와 같다. 여기에 약간의 편의 기능이 추가된다.
- 인터페이스의 메서드는 모두
public,abstract이다.- 메서드에
public abstract를 생략할 수 있다. 참고로 생략이 권장된다.- 인터페이스는 다중 구현(다중 상속)을 지원한다.
예시
public interface InterfaceAnimal { void sound(); void move(); }
//Dog public class Dog implements InterfaceAnimal { @Override public void sound() { System.out.println("멍멍"); } @Override public void move() { System.out.println("개 이동"); } } //Cat public class Cat implements InterfaceAnimal { @Override public void sound() { System.out.println("냐옹"); } @Override public void move() { System.out.println("고양이 이동"); } } //Caw public class Caw implements InterfaceAnimal { @Override public void sound() { System.out.println("음매"); } @Override public void move() { System.out.println("소 이동"); } }
public class InterfaceMain { public static void main(String[] args) { //인터페이스 생성 불가 //InterfaceAnimal interfaceMain1 = new InterfaceAnimal(); Cat cat = new Cat(); Dog dog = new Dog(); Caw caw = new Caw(); soundAnimal(cat); soundAnimal(dog); soundAnimal(caw); } //동물이 추가 되어도 변하지 않는 코드 private static void soundAnimal(InterfaceAnimal animal) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); } }
인터페이스를 사용해야 하는 이유
- 제약: 인터페이스를 만드는 이유는 인터페이스를 구현하는 곳에서 인터페이스의 메서드를 반드시 구현해라는 규약(제약)을 주는 것이다. USB 인터페이스를 생각해보자. USB 인터페이스에 맞추어 키보드, 마우스를 개발하고 연결해야 한다. 그렇지 않으면 작동하지 않는다. 인터페이스의 규약(제약)은 반드시 구현해야 하는 것이다. 그런데 순수 추상 클래스의 경우 미래에 누군가 그곳에 실행 가능한 메서드를 끼워 넣을 수 있다. 따라서 이런 문제를 원천 차단할 수 있다.
- 다중 구현: 자바에서 클래스 상속은 부모를 하나만 지정할 수 있다. 반면에 인터페이스는 부모를 여러명 두는 다중구현(다중 상속)이 가능하다.
OCP 원칙
좋은 객체 지향 설계 원칙 중 하나로 OCP원칙이라는 것이 있다.
- Open for extension : 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
- Closed for modification : 기존의 코드는 수정되지 않아야 한다.
- Car 인터페이스를 사용해서 새로운 차량을 자유롭게 추가할 수 있다. Car 인터페이스를 구현해서 기능을 추가할 수 있다는 의미이다.
- 그리고 Car 인터페이스를 사용하는 클라이언트 코드인 Driver도 Car 인터페이스를 통해 새롭게 추가된 차량을 자유롭게 호출할 수 있다. 이것이 확장에 열려있다는 의미이다.
- 새로운 차를 추가하게 되면 기능이 추가되기 때문에 기존 코드의 수정은 불가피하다. 당연히 어딘가의 코드는 수정해야 한다.
- 새로운 자동차를 추가할 때, 가장 영향을 받는 중요한 클라이언트는 바로 Car의 기능을 사용하는 Driver이다.
- 핵심은 Car 인터페이스를 사용하는 클라이언트인 Driver의 코드를 수정하지 않아도 된다는 뜻이다.
main()과 같이 새로운 차를 생성하고Driver에게 필요한 차를 전달해주는 역할은 당연히 코드 수정이 발생한다.