-
[Java] 다형성 (Polymorphism)Java 2024. 11. 7. 22:30
1. 다형성이란?
- 객체가 여러 형태를 가질 수 있는 능력
- 자바의 객체지향 개념을 이해하기 위한 중요한 개념이다.
- 다형성을 활용하면 코드의 확장성, 유연성을 향상시켜 유지보수하기 쉬워진다.
- 다형성은 부모와 자식 관계 에서만 사용할 수 있다.
- 인터페이스와 구현 클래스도 부모 - 자식 관계이다.
- 부모 타입의 참조변수는 자식 타입의 객체를 가리킬 수 있다.
2. 부모 타입의 참조변수로 자식 타입의 객체 참조하기
- 부모 - 자식 관계로 맺어진 Phone 클래스와 SmartPhone 클래스가 있다.
- 지금까지는 A 타입 변수에는 A 타입의 값만 대입할 수 있었다.
- 부모 - 자식 관계인 경우에는 부모 타입의 참조변수에 자식 객체를 참조하는 참조 값을 대입할 수 있다.
- Phone p = new SmartPhone();
- 중요한 것은 참조변수 p가 SmartPhone 객체를 가리키고 있지만, SmartPhone 클래스의 고유 멤버에는 접근할 수 없다는 것이다.
public class Phone { boolean power; String color; int batteryCapacity; void powerOn() { if (power == true) return; power = !power; } void powerOff() { if (power == false) return; power = !power; } void call(String phoneNumber) { System.out.println("calling to " + phoneNumber); } }
public class SmartPhone extends Phone { void videoCall(String phoneNumber) { System.out.println("video calling to " + phoneNumber); } void internetSearch() { System.out.println("SmartPhone.internetSearch"); } }
public class ClassTestApp { public static void main(String[] args) { // 부모 타입 참조변수로 자식 객체 참조 가능 Phone p = new SmartPhone(); p.power = true; p.batteryCapacity = 10; p.call("119"); // p.videoCall("112"); // Phone에 선언된 멤버만 사용 가능 // p.internetSearch(); } }
3. 참조변수의 형변환
- 기본 타입 변수를 형변환하면 값 자체가 바뀌었다.
- (int) 3.6 → 3
- 참조변수를 형변환하면 사용할 수 있는 멤버의 개수가 달라질 뿐 그 외에는 변함이 없다.
- 부모 - 자식 관계에서 참조변수는 서로의 타입으로 형변환 될 수 있다.
- 자동 형변환 : 업 캐스팅(자식 타입 → 부모 타입)
- 수동 형변환 : 다운 캐스팅(부모 타입 → 자식 타입)
Phone p = new SmartPhone(); Phone p = (Phone) new SmartPhone(); // 위 코드는 (Phone)이 생략되어 있던 것이다. SmartPhone sp = new SmartPhone(); Phone p1 = sp // OK. 자식 -> 부모
Phone p = new SmartPhone(); // p.internetSearch(); // 에러! SmartPhone sp = (SmartPhone) p; sp.internetSearch(); // OK
- 다운 캐스팅 시 수동 형변환을 해야하는 이유
- 업 캐스팅은 항상 안전하다.
- 다운 캐스팅은 안전하지 않을 수 있다.
- 참조변수가 가리키는 실제 객체가 무엇인지가 중요하다.
- 다운 캐스팅은 형변환하려는 참조변수가 가리키는 객체가 자신의 자식일 때만 가능하다.
- 따라서 참조변수를 형변환 하기 전 반드시 instanceof 연산자 통해서 형변환 가능 여부를 확인해야 한다.
Phone p = new Phone(); SmartPhone sp = (SmartPhone) p; // 컴파일은 되지만 런타임 에러 발생!
Phone p = new Phone(); if (p instanceof SmartPhone) { // false SmartPhone sp = (SmartPhone) p; } else { System.out.println("p는 SmartPhone으로 형변환 될 수 없습니다."); }
4. 매개변수의 다형성
- 메서드 호출 시 해당 메서드가 지정한 매개변수 타입만 매개변수로 받을 수 있다.
- 하지만 다형성을 활용하면 여러 타입의 매개변수를 받을 수 있다.
public class Product { private int price; private int bonusPoint; public Product(int price) { this.price = price; this.bonusPoint = (int) (price / 10.0); } public int getPrice() { return price; } public int getBonusPoint() { return bonusPoint; } }
public class Buyer { private int money = 10000; private int bonusPoint = 0; public void buy(Product product) { // 매개변수의 다형성 if (money < product.getPrice()) { System.out.println("잔액이 부족합니다."); return; // 잔액이 부족하면 물건 구매 X } money -= product.getPrice(); bonusPoint += product.getBonusPoint(); // 참조변수를 출력하면 해당 참조변수의 toString() 메서드가 호출됨 // product.toString()과 같은 것! System.out.println(product + "를 구매했습니다."); } public void checkMoney() { System.out.println("현자 잔액은 " + money + "원 입니다."); } public void checkBonusPoint() { System.out.println("현재 보너스포인트는 " + bonusPoint + " 입니다."); } }
public class Tv extends Product { public Tv(int price) { super(price); } public String toString() { return "Tv"; // Object 클래스의 toString() 오버라이드 } }
public class Computer extends Product { public Computer(int price) { super(price); } public String toString() { return "Computer"; } }
public class Radio extends Product { public Radio(int price) { super(price); } public String toString() { return "Radio"; } }
- Product 클래스를 상속 받는 Tv, Computer, Radio 클래스를 정의하고 물건을 구입하는 Buyer 클래스를 만들었다.
- 여기서 중요한 것은 Buyer 클래스의 buy() 메서드이다.
public void buy(Product product) { // 매개변수의 다형성 if (money < product.getPrice()) { System.out.println("잔액이 부족합니다."); return; }
- 메서드의 매개변수가 참조변수이면 해당 클래스의 자식 객체는 모두 매개변수로 들어올 수 있다.
- 다형성을 활용하면 하나의 메서드가 여러 타입의 매개변수를 사용할 수 있다.
- 다형성이 없었다면 제품마다 buy 메서드를 여러 개 오버로딩 해야하지만 다형성이 성립하기 때문에 위와 같은 코드가 가능해졌다.
- 이제 실행 클래스를 만들어 위의 클래스들을 실행해보자.
public class ProductApp { public static void main(String[] args) { Buyer buyer = new Buyer(); // 다형성! buyer.buy(new Computer(2000)); buyer.buy(new Tv(3000)); buyer.buy(new Radio(1000)); buyer.checkMoney(); buyer.checkBonusPoint(); } }
5. 하나의 배열로 여러 종류의 객체 다루기
- 기본 타입을 저장하는 배열의 경우 해당 타입의 값만 저장할 수 있었다.
- 하지만 참조 타입의 배열은 해당 타입의 자식 객체를 모두 저장할 수 있다.
- 위 특성을 활용해 Buyer 클래스에 기능을 추가해보자.
public class Buyer { private int money = 100000; private int bonusPoint = 0; Product[] cart = new Product[10]; // 참조변수 타입 배열 선언 int count = 0; public void buy(Product product) { if (money < product.getPrice()) { System.out.println("잔액이 부족합니다."); return; } if (count >= cart.length) { System.out.println("카트 용량이 부족합니다."); return; // 카트 용량이 부족하면 물건 구매 X } money -= product.getPrice(); bonusPoint += product.getBonusPoint(); System.out.println(product + "를 구매했습니다."); cart[count++] = product; // 자식 객체 모두 저장 가능! } public void summary() { int sum = 0; int bonus = 0; String itemList = ""; for (Product product : cart) { if (product == null) break; // 저장된 물건이 없다면 종료 sum += product.getPrice(); bonus = product.getBonusPoint(); itemList += product + " "; // 결합연산자로 사용될 때도 toString() 호출 } System.out.printf("구매한 물건의 금액은 총 %d원입니다.%n", sum); System.out.printf("구매한 물건 리스트입니다. %s%n", itemList); System.out.printf("현재 보너스포인트는 %d점입니다.%n", bonus); } }
- cart 배열은 Product 타입이므로 Product를 포함한 자식 객체를 모두 저장할 수 있다.
- 이와 같이 객체를 저장하는 배열을 객체 배열이라고 하며, 객체 배열에 저장되는 값은 모두 참조변수이다.
- 따라서 객체 배열은 곧 참조변수 배열이다.
- 참조변수 cart[0] = new Tv(1000);
- 참조변수 cart[1] = new Computer(2000);
- 참조변수 cart[2] = new Radio(500);
- 각 참조변수에는 객체 데이터가 저장된 메모리 위치를 가리키는 참조값이 저장된다.
6. 인터페이스와 다형성
- 인터페이스와 해당 인터페이스를 구현한 클래스 간에도 부모 - 자식 관계가 적용된다.
- 따라서 인터페이스와 구현 클래스 간에도 다형성이 적용된다.
- 인터페이스 타입 참조변수가 그 인터페이스를 구현한 클래스의 객체를 가리킬 수 있다.
- 메서드의 반환 타입, 메서드의 매개변수에 인터페이스가 지정될 수 있다.
- 반환 타입이 인터페이스라는 것은 해당 인터페이스를 구현한 클래스의 객체를 반환한다는 것이다.
- 매개변수가 인터페이스라는 것은 해당 인터페이스를 구현한 클래스의 객체만 매개변수로 들어올 수 있다는 것이다.
public abstract class Unit { int x; int y; public abstract void move(int x, int y); void stop() { System.out.println("멈춥니다."); } }
public interface Fightable { void move(int x, int y); // 매개변수 타입이 인터페이스인 메서드 void attack(Fightable fightable); }
- 기본적인 유닛의 공통 속성을 정의하고 유닛마다 이동 방법이 다를 수 있으므로 move()를 추상 메서드로 정의한 추상 클래스 Unit
- 모든 유닛이 공격할 수 있는 것은 아니므로 Fightable 인터페이스를 따로 정의하여 공격할 수 있는 유닛은 해당 인터페이스를 자신에게 맞게 구현하도록 함
public class Ironman extends Unit implements Fightable { public void attack(Fightable fightable) { System.out.println(fightable + "을 공격합니다."); } void move(int x, int y) { System.out.printf("(%d, %d)로 이동합니다.%n", x, y); } public String toString() { return "IronMan"; } }
public class Spiderman extends Unit implements Fightable { public void attack(Fightable fightable) { System.out.println(fightable + "을 공격합니다."); } void move(int x, int y) { System.out.printf("(%d, %d)로 이동합니다.%n", x, y); } public String toString() { return "Spiderman"; } }
public class UnitApp { public static void main(String[] args) { Fightable ironman = getFightable("Ironman"); // new Ironman(); Fightable spiderman = getFightable("Spiderman"); // new Spiderman(); ironman.attack(spiderman); } // 반환 타입이 인터페이스인 메서드 public static Fightable getFightable(String fighterName) { if (fighterName.equals("Ironman")) { return new Ironman(); } else if (fighterName.equals("Spiderman")) { return new Spiderman(); } else { return null; } } }
- Fightable 인터페이스의 attack() 메서드를 보자.
- attack() 메서드는 매개변수로 Fightable 인터페이스를 구현한 클래스의 객체만 받을 수 있다.
- 즉, 싸울 수 있는 대상만을 공격한다고 이해하면 쉽다.
- 이제 실행 클래스인 UnitApp 클래스를 보면 반환 타입이 인터페이스인 메서드를 확인할 수 있다.
- getFightable() 메서드는 Fightable 인터페이스를 구현한 클래스의 객체만 반환한다.
- Fightable ironman = getFightable("Ironman");
- Fightable spiderman = getFightable("Spiderman");
- Ironman과 Spiderman 클래스는 Fightable 인터페이스를 구현했기 때문에 다형성이 성립된다.
- 따라서 Fightable 타입의 참조변수가 Ironman과 Spiderman 객체를 모두 참조할 수 있다.
- Fightable 타입의 참조변수 ironman은 Ironman 객체를 참조하고 있다.
- 따라서 ironman의 attack() 메서드를 호출하면 Ironman 클래스에서 오버라이드된 attack() 메서드가 호출된다.
- ironman.attack(spiderman);
- Spiderman 객체를 참조하고 있는 spiderman 참조변수도 Fightable 인터페이스를 구현했기 때문에 attack() 메서드의 매개변수에 들어갈 수 있다.
7. 선언과 구현의 분리
- 인터페이스의 장점 중 선언과 구현을 분리할 수 있다는 것이 있다.
- 인터페이스를 통해 선언과 구현을 분리하면 유지보수하기 쉬워진다.
- 선언과 구현을 분리한다는 것은 선언은 인터페이스에 하고 구현은 하위 클래스가 하도록 하는 것이다.
- 선언과 구현이 분리되지 않았을 때
- B 클래스는 핵심 기능인 serviceVer1() 메서드를 직접 선언하여 구현하고 있다.
- 해당 기능이 필요한 A 클래스는 B 클래스의 메서드를 직접 사용하고 있다.
- 해당 기능을 계속 사용한다면 문제가 없겠지만 만약 다른 기능이 나와서 기능을 대체해야 한다면..?
public class A { public void test(B b) { b.serviceVer1(); } }
public class B { public void serviceVer1() { System.out.println("B 클래스 사용"); } }
public class HelloApp { public static void main(String[] args) { A a = new A(); a.test(new B()); } }
새로운 기능의 등장으로 A 클래스는 더 이상 B 클래스의 기능을 사용하지 않고 C 클래스를 사용해야 한다. 그렇게 되면 실행 클래스인 HelloApp의 변경은 불가피하지만 B 클래스에 의존적이던 A 클래스도 변경해주어야 한다. 이는 A 클래스가 B 클래스와 강하게 결합 되어 있었기 때문이다. 코드의 변경을 최소화하기 위해서, 다시 말해 유지보수성을 향상시키기 위해서는 A 클래스와 A 클래스가 사용하는 대상 간의 결합도를 느슨하게 해야 한다. 그렇게 하기 위해서는 핵심 기능의 선언과 구현을 분리 해야 한다.
public class C { public void serviceVer2() { System.out.println("C 클래스 사용"); } }
public class A { public void test(**C c**) { **c.serviceVer2();** } }
public class HelloApp { public static void main(String[] args) { A a = new A(); a.test(**new C()**); } }
- 선언과 구현이 분리되었을 때
- 우선 A 클래스가 사용해야 하는 핵심 기능인 메서드를 인터페이스에 정의한다.
- 예전과 다르게 A 클래스와 해당 메서드를 구현한 클래스 사이에 인터페이스가 있다.
- A 클래스는 인터페이스에만 의존하며, B와 C 중 어느 클래스를 사용하는지 알 필요가 없다.
- 이제 A 클래스의 test() 메서드는 인터페이스를 구현한 어떠한 객체도 받아 처리할 수 있는 유연성을 가지게 되었다.
- 핵심 기능을 업그레이드한 C 클래스를 사용해도 A 클래스를 변경해야 할 필요가 없어졌다.
- 다시 말해, 이후 새로운 클래스가 인터페이스를 구현하기만 하면, A 클래스의 코드를 변경하지 않고도 해당 클래스의 객체를 test 메서드에 전달할 수 있다.
public class A { public void test(I i) { i.service(); } }
public interface I { void service(); }
public class B implements I { public void service() { System.out.println("B 클래스 사용"); } }
public class C implements I { public void service() { System.out.println("C 클래스 사용"); } }
public class HelloApp { public static void main(String[] args) { A a = new A(); // a.test(new B()); **a.test(new C());** } }
'Java' 카테고리의 다른 글
[Java] I/O 정리 (1) 2024.11.09 [Java] Charset과 문자 인코딩 (0) 2024.11.08 [Java] Dump File에 대해서 (0) 2024.10.31 [Java] Native Resource와 native 예약어 (1) 2024.10.30 [Java] JVM의 메모리 영역 (JVM Memory Structure) (0) 2024.10.29