처음부터 차근차근

[Java] 다형성 본문

Language/Java

[Java] 다형성

HangJu_95 2024. 1. 1. 18:29
728x90

객체 지향 프로그래밍의 대표적인 특징으로는 캡슐화, 상속, 다형성이 존재합니다.

좋은 개발자가 되기 위해서는 다형성에 대한 이해가 필수입니다.

다형성(Polymorphism)이란?

  • "다양한 형태", "여러 형태", 즉, 여러 가지 형태를 가질 수 있는 능력을 의미
  • 프로그래밍에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 뜻합니다.
  • 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 프로그램적으로 구현했습니다.

 

다형성을 이해하기 위해서는 크게 2가지 핵심 이론을 알아야 합니다.

  • 다형적 참조
  • 메서드 오버라이딩

다형적 참조

다형적 참조를 이해하기 위해 간단한 상속 관계 코드를 만들어보겠습니다.

package poly.basic;

public class Parent {
    public void parentMethod() {
        System.out.println("Parent.parentMethod");
    }
}
package poly.basic;

public class Child extends Parent {
    public void childMethod() {
        System.out.println("Child.childMethod");
    }
}
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(); // 부모 타입에 있는 메서드는 사용 가능
//        poly.childMethod(); // 자식의 기능은 호출할 수 없다. 컴파일 오류 발생

//        Child child1 = new Parent(); // 자식은 부모를 담을 수 없다.
    }
}

그림을 통해 코드를 분석해보겠습니다.

 

1. 부모 타입의 변수가 부모 인스턴스 참조

Parent -> Parent: parent.parentMethod()

 2. 자식 타입의 변수가 자식 인스턴스 참조

Child -> Child: child.childMethod()

3. 다형적 참조 : 부모 타입의 변수가 자식 인스턴스 참조(가장 핵심 내용)

Parent -> Child: poly.parentMethod()

  • 부모 타입의 변수가 자식 인스턴스를 참조
  • Child 인스턴스를 만들었는데, 이 경우 자식 타입인 Child를 생성했기 때문에 메모리 상에 Child와 Parent가 모두 생성됩니다.
  • 생성된 참조값을 parent 타입의 변수인 poly에 담아둔다.

부모는 자식을 담을 수 있다.

  • Parent poly는 부모 타입입니다. new Child()를 통해 생성된 결과는 Child 타입입니다.
  • 자바에서는 부모 타입에 자식 타입을 담을 수 있습니다.
  • 반대로 자식 타입은 부모 타입에 담을 수 없습니다.

인스턴스 실행

  • poly.parentMethod()를 호출하면 먼저 참조값을 사용해서 인스턴스를 찾습니다. 그리고 다음으로 인스턴스 안에서 실행할 타입도 찾아야 합니다.
  • poly는 Parent 타입입니다. 따라서 Parent 클래스부터 시작해서 필요한 기능을 찾고, 그 안에 존재하는 parentMethod()를 호출합니다.
Java에서는 부모 타입은 물론이고, 자신을 기준으로 모든 자식 타입을 참조할 수 있습니다. 이것이 바로 다양한 형태를 참조할 수 있다고 해서 다형적 참조라고 합니다.
다형성 참조의 핵심은 "부모는 자식을 품을 수 있다는 것"입니다.

다형적 참조의 한계

Parent -> Child: poly.childMethod()

이렇게 자식을 참조한 상황에서 poly가 자식 타입인 child에 있는 childMethod를 호출할 수 있을까요??

  • 먼저 참조값을 통해 인스턴스를 찾습니다. 그리고 인스턴스 안에서 실행할 타입을 찾아야 합니다.
  • 호출자인 poly는 Parent 타입입니다. 따라서 Parent 클래스부터 시작해서 필요한 기능을 찾습니다.
  • 그러나 상속 관계는 부모 방향으로 찾아 올라갈 수 있지만(extends) 자식 방향으로 찾아 내려갈 수 없습니다.
  • 따라서 childMethod를 찾을 수 없으므로 컴파일 오류가 발생합니다.

이런 경우 childMethod()를 호출하고 싶으면 캐스팅을 진행하면 됩니다.

다형성과 캐스팅

  • upcasting : 부모 타입으로 변경
  • downcasting : 자식 타입으로 변경

간단한 캐스팅 예제를 그림과 같이 한번 보겠습니다.

package poly.basic;

public class CastingMain1 {
    public static void main(String[] args) {
        // 부모 변수가 자식 인스턴스 참조(다형적 참조)
        Parent poly = new Child();
        // 단 자식의 기능을 호출할 수 없다. 컴파일 오류 발생
//        poly.childMethod();

        // 다운 캐스팅 (부모 타입 -> 자식 타입)
        Child child = (Child) poly;
        child.childMethod();
    }
}

  • 위 그림과 같이 호출하는 타입을 자식인 Child 타입으로 변경하면 인스턴스의 Child에 있는 childMethod()를 호출할 수 있습니다.
  • 부모는 자식을 담을 수 있지만, 자식은 부모를 담을 수 없기 때문에, 타입을 변경하였습니다.
  • 이렇게 자식 타입으로 타입을 변경하는 것을 다운캐스팅이라고 합니다.

실행 순서

Child child = (Child) poly //다운캐스팅을 통해 부모타입을 자식 타입으로 변환한 다음에 대입 시도
Child child = (Child) x001 //참조값을 읽은 다음 자식 타입으로 지정
Child child = x001 //최종 결과

캐스팅의 종류

일시적 다운 캐스팅

위 예제는 다운 캐스팅하여 변수에 담아두었습니다. 하지만 이런 과정 없이 일시적으로 다운캐스팅이 필요한 경우, 다음과 같이 사용하면 됩니다.

package poly.basic;

public class CastingMain2 {
    public static void main(String[] args) {
        // 부모 변수가 자식 인스턴스 참조(다형적 참조)
        Parent poly = new Child();
        //단 자식의 기능은 호출할 수 없다. 컴파일 오류 발생
        //poly.childMethod();

        //일시적 다운캐스팅 - 해당 메서드를 호출하는 순간만 다운캐스팅
        ((Child) poly).childMethod();
    }
}

((Child) poly).childMethod() //다운캐스팅을 통해 부모타입을 자식 타입으로 변환 후 기능 호출
((Child) x001).childMethod() //참조값을 읽은 다음 자식 타입으로 다운캐스팅

해당 참조값을 꺼내고, 꺼낸 참조값이 Child 타입이 되는 것이고, poly 타입은 Parent로 그대로 유지됩니다.

업캐스팅

다운캐스팅과 반대로 현재 타입을 부모 타입으로 변경하는 것을 업캐스팅이라 합니다.

 

package poly.basic;

// upcasting vs downcasting
public class CastingMain3 {
    public static void main(String[] args) {
        Child child = new Child();
        Parent parent1 = (Parent) child; // 업캐스팅은 생략 가능, 생략 권장
        Parent parent2 = child; // 업캐스팅 생략

        parent1.parentMethod();
        parent2.parentMethod();
    }
}

업캐스팅은 생략할 수 있습니다. (다운캐스팅은 생략 불가능)

참고로 업 캐스팅은 매우 자주 사용하기 때문에 생략을 권장합니다.

자바에서 부모는 자식을 담을 수 있습니다. 하지만 그 반대는 안됩니다.

다운캐스팅과 주의점

업캐스팅은 생략해도 되고, 다운캐스팅은 왜 개발자가 직접 명시적으로 캐스팅 해야 될까요??

그 이유는, 다운캐스팅은 잘못하면 심각한 런타임 오류가 발생할 수 있습니다.

package poly.basic;

// 다운캐스팅을 자동으로 하지 않는 이유
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(); // 실행 불가
    }
}

실행 결과

에러 발생 이유

  • Parent 타입으로 객체를 먼저 생성하였습니다. 이를 Child 타입으로 다운 캐스팅을 진행합니다.
  • 그런데 parent2는 Parent로 생성이 되어있습니다. 따라서 메모리 상에 Child 자체가 존재하지 않으므로, Child 자체를 사용할 수 없는 것입니다.
  • 자바에서는 이렇게 사용할 수 없는 타입으로 다운캐스팅을 하는 경우에 ClassCastException이라는 예외를 발생시킵니다. (런타임 에러이므로, 심각한 수준)

업캐스팅이 안전하고 다운캐스팅이 위험한 이유

업캐스팅의 경우 이런 문제가 절대로 발생하지 않는다. 왜냐하면 객체를 생성하면 해당 타입의 상위 부모 타입은 모두 함께 생성된다! 따라서 위로만 타입을 변경하는 업캐스팅은 메모리 상에 인스턴스가 모두 존재하기 때문에 항상 안전하다. 따라서 캐스팅을 생략할 수 있다.
반면에 다운캐스팅의 경우 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 왜냐하면 객체를 생성하면 부모 타입은 모두 함께 생성되지만 자식 타입은 생성되지 않는다. 따라서 개발자가 이런 문제를 인지하고 사용해야 한다는 의미로 명시적으로 캐스팅을 해주어야 한다.

Upcasting
Downcasting

instanceof

참조변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 instanceof 연산자를 사용할 수 있습니다.

다운캐스팅을 수행하기 전에는 먼저 istanceof 연산자를 사용해서 원하는 타입으로 변경이 가능한지 확인한 다음에 다운캐스팅을 수행하는 것이 안전합니다.

package poly.basic;

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();
        }
    }
}

체크하고 싶은 변수 instanceof 인스턴스 타입(클래스명)
  • 오른쪽에 있는 타입에 왼쪽에 있는 인스턴스의 타입이 들어갈 수 있는지 대입이 가능하면 true, 불가능하면 false가 됩니다.
new Parent() instanceof Parent
Parent p = new Parent() //같은 타입 true

new Child() instanceof Parent
Parent p = new Child() //부모는 자식을 담을 수 있다. true

new Parent() instanceof Child
Child c = new Parent() //자식은 부모를 담을 수 없다. false

new Child() instanceof Child
Child c = new Child() //같은 타입 true

자바 16 - Pattern Matching for instanceof

instanceof를 사용하면서 동시에 변수를 선언할 수 있습니다.

package poly.basic;

public class CastingMain6 {
    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 child) {
            System.out.println("Child 인스턴스 맞음");
            child.childMethod();
        }
    }
}

다형성과 메서드 오버라이딩

메서드 오버라이딩에서 가장 중요한 점은, 오버라이딩 된 메서드가 항상 우선권을 가진다는 점입니다.

간단한 예시를 통해 다형성과 메서드 오버라이딩을 알아보겠습니다.

package poly.overriding;

public class Parent {
    public String value = "parent";

    public void method() {
        System.out.println("Parent.method");
    }
}
package poly.overriding;

public class Child extends Parent {
    public String value = "child";

    @Override
    public void method() {
        System.out.println("Child.method");
    }
}
package poly.overriding;

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(); //메서드 오버라이딩!
    }
}

실행 결과

Parent -> Child

 

  • poly 변수는 Parent 타입입니다. 따라서 poly.value, poly.method()를 호출하면 인스턴스의 Parent 타입에서 기능을 찾아 실행합니다.
    • poly.value : Parent 타입에 있는 value 값을 읽는다
    • poly.method() : parent 타입에 있는 method를 실행하려고 하지만, 하위 타입인 Child.method가 오버라이딩 되어 있습니다. 오버라이딩 된 메서드는 항상 우선권을 가집니다. 따라서 Child.method가 실행됩니다.
오버라이딩 된 메서드는 항상 우선권을 가진다. 오버라이딩은 부모 타입에서 정의한 기능을 자식 타입에서 재정의하는것이다. 만약 자식에서도 오버라이딩 하고 손자에서도 같은 메서드를 오버라이딩을 하면 손자의 오버라이딩 메서드가 우선권을 가진다. 더 하위 자식의 오버라이딩 된 메서드가 우선권을 가지는 것이다.

다형성을 활용한 예제

지금까지 학습한 다형성을 왜 사용하는지, 그 장점을 알아보기 위해 예제를 한번 만들어봤습니다.

가장 많이쓰이는 동물 소리 문제로 예제를 만들겠습니다.

정말 많이 나오는 다형성 예제

개, 고양이, 소의 울음 소리를 테스트하는 프로그램을 작성해보겠습니다.

package poly.ex1;

public class Dog {
    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex1;

public class Cat {
    public void sound() {
        System.out.println("냐옹");
    }
}
package poly.ex1;

public class Caw {
    public void sound() {
        System.out.println("음매");
    }
}
package poly.ex1;

public class AnimalSoundMain {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

//        Caw[] cawArr = {cat, dog, caw}; // 컴파일 오류 발생

        System.out.println("동물 소리 테스트 시작");
        dog.sound();
        System.out.println("동물 소리 테스트 종료");

        System.out.println("동물 소리 테스트 시작");
        cat.sound();
        System.out.println("동물 소리 테스트 종료");

        System.out.println("동물 소리 테스트 시작");
        soundCaw(caw);
        System.out.println("동물 소리 테스트 종료");

//        soundCaw(cat); // 컴파일 에러 발생
    }

    private static void soundCaw(Caw caw) {
        System.out.println("동물 소리 테스트 시작");
        caw.sound();
        System.out.println("동물 소리 테스트 종료");
    }
}

단순히 개, 고양이, 소 동물들의 울음 소리를 출력하는 프로그램입니다. 여기서 새로운 동물이 추가되면, 새로운 클래스를 만들고 출력하는 코드도 작성해야 합니다.

또한 코드에 중복되는 부분이 있는데, 제거할 수 있을까요??

 

중복을 제거하기 위해서는 메서드를 사용하거나 또는 배열과 for문을 사용해야 합니다.

그러나 Dog, Cat, Cow는 서로 다른 클래스이기 때문에 메서드나 배열을 함께 사용하는 것이 불가능합니다.

문제의 핵심은 바로 타입이 다르다는 점입니다. 이 부분을 다형적 참조메서드 오버라이딩을 활용하여 수정해보겠습니다.

Animal 클래스를 추가하고 각각 상속

다형성을 사용하기 위해 여기서는 상속 관계를 사용했습니다.

Animal 이라는 부모 클래스를 만들고 sound() 메서드를 정의하였습니다. 이는 오버라이딩 할 목적으로 만들어졌습니다.

package poly.ex2;

public class Animal {
    public void sound() {
        System.out.println("동물 울음 소리");
    }
}
package poly.ex2;

public class Cat extends Animal{
    @Override
    public void sound() {
        System.out.println("냐옹");
    }
}
package poly.ex2;

public class Caw extends Animal{
    @Override
    public void sound() {
        System.out.println("음매");
    }
}
package poly.ex2;

public class Dog extends Animal{
    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex2;

public class AnimalPolyMain1 {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();
        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(caw);
    }
    //동물이 추가 되어도 변하지 않는 코드
    private static void soundAnimal(Animal animal) {
        System.out.println("동물 소리 테스트 시작");
        animal.sound();
        System.out.println("동물 소리 테스트 종료");
    }
}

코드 분석

animal 변수의 타입은 Animal이므로 Dog인스턴스에 있는 Animal클래스 부분을 찾아서 sound()메서드를 실행한다. 그런데 하위 클래스인 Dog 에서 sound()메서드를 오버라이딩 했다. 따라서 오버라이딩 한 메서드가 우선권을 가진다.

 

이 코드는 다형적 참조 덕분에 animal 변수는 자식인 Dog, Cat, Caw의 인스턴스를 참조할 수 있습니다.

또한 메서드 오버라이딩 덕분에 animal.sound()를 호출해도 Dog.sound(), Cat.sound(), Caw.sound()와 같이 각 인스턴스의 메서드를 호출할 수 있습니다. 만약 자바에 메서드 오버라이딩이 없었다면 모두 Animal의 sound()가 호출되었을 것입니다.

 

이는 배열과 for문을 사용해서 중복을 제거할 수도 있습니다.

package poly.ex2;

public class AnimalPolyMain2 {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();
        Animal[] animalArr = {dog, cat, caw};

        // 변하지 않는 부분
        for (Animal animal : animalArr) {
            System.out.println("동물 소리 테스트 시작");
            animal.sound();
            System.out.println("동물 소리 테스트 종료");
        }
    }
}

조금 더 개선해보자면 이런 코드도 가능합니다.

package poly.ex2;

public class AnimalPolyMain3 {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat(), new Caw()};
        for (Animal animal : animals) {
            soundAnimal(animal);
        }
    }
    //동물이 추가 되어도 변하지 않는 코드
    private static void soundAnimal(Animal animal) {
        System.out.println("동물 소리 테스트 시작");
        animal.sound();
        System.out.println("동물 소리 테스트 종료");
    }
}

이를 통해서 새로운 동물이 추가되어도 soundAnimal() 메서드는 코드 변경 없이 유지할 수 있습니다.

이렇게 할 수 있는 이유는 이 메서드는 Dog, Cat, Caw 같은 구체적인 클래스를 참조하는 것이 아니라 Animal 이라는 추상적인 부모를 참조하기 때문입니다.

 

하지만 아직까지 남은 단점이 있습니다.

  • Animal 클래스를 생성할 수 있는 문제 (동물은 추상적인 클래스이며, 실재로 존재하는 것은 이상한 개념입니다.)
  • Animal 클래스를 상속 받는 곳에서 sound() 메서드 오버라이딩을 하지 않을 가능성이 존재.
    누군가 실수로 메서드 오버라이딩 하는 것을 빠트릴 수도 있습니다.

이러한 문제를 해결해주는 도구는 추상 메서드와 인터페이스로 해결할 수 있습니다.

다음 포스팅은 추상 메서드와 인터페이스를 알아보겠습니다.

참고

자바의 정석_기초편

 

김영한의 실전 자바 - 기본편 강의 - 인프런

실무에 필요한 자바 객체 지향의 핵심 개념을 예제 코드를 통해 쉽게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다

www.inflearn.com

'Language > Java' 카테고리의 다른 글

[Java] Interface  (1) 2024.01.02
[Java] abstract 추상 클래스 & 추상 메서드  (1) 2024.01.02
[Java] final  (1) 2023.12.31
[Java] static  (2) 2023.12.29
[Java] Java 메모리 구조  (1) 2023.12.29