ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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