GYUD-TECH

[스터디: 오브젝트] 다형성 본문

스터디

[스터디: 오브젝트] 다형성

GYUD 2024. 3. 7. 19:08

 

객체지향의 가장 중요한 특징은 다형성이라고 생각한다.

 

상속, 합성 등 다형성을 구현하기 위한 방법에는 다양한 것들이 있다.

이번 장에서는 상속에서 다형성이 어떻게 동작하는지에 대해 공부한다.

 

기존에 당연하게 사용했던 개념을 깊이 공부하고, 스스로를 반성을 하는 계기도 되었던 챕터이다.

 


다형성

다형성은 하나의 추상 인터페이스에 대한 코드를 작성하고, 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력이다.

추상화에 의존하기 때문에 런타임에 동적으로 구현을 바꿀 수 있어 변경에 대처하기 쉽다는게 가장 큰 특징이다.

 

이러한 다형성은 4가지 종류로 분류할 수 있다.

오버로딩 다형성

하나의 클래스 안에 이름은 같고, 시그니처는 다른 메서드가 존재하는 것을 메서드 오버로딩이라고 한다.

public class Money {
    public Money plus(Money amount) {...}
    public Money plus (long amount) {...}
}

자바에서는 위와 같이 메서드 오버로딩을 통해서 같은 이름의 메서드라도 다른 구현을 가지도록 할 수 있다.

같은 메서드 명이라는 추상화를 활용하여 다른 구현을 가질 수 있는 것이기 때문에 다형성의 일부라고 할 수 있다.

 

 

강제 다형성

언어가 지원하는 자동 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용 가능한 것을 의미한다.

 

자바의 + 연산자는 피연산자가 모두 정수인 경우에는 피연산자의 덧셈 연산을 수행하도록 정의되어 있다.

하지만 한개 이상의 피연산자가 String 타입이라면 문자열로 인식하여 두 문자열을 이어주는 기능을 수행한다.

int one = 1, two = 2;
System.out.println(one + two);  // 3

String strTwo = "2";
System.out.println(one + strTwo);  // 12

동일한 연산자를 사용하였음에도 그 작업이 다르게 수행되기 떄문에 다형성의 한 예시라고 할 수 있다.

 

 

매개변수 다형성

클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입을 지정하는 방식을 의미한다.

 

자바의 List 인터페이스는 다양한 타입의 변수를 저장하기 위하여 제네릭 타입을 사용하는데 제네릭 타입을 사용하여 구체적인 타입을 지정하지 않은 것이기 때문에 다형성의 한가지 예시에 해당한다.

public interface List<E> extends Collection<E> {...}

 

 

포함 다형성

추상화된 클래스에 메시지를 전송하면 구현한 객체에 따라서 다른 행동이 실행되는 것을 의미한다.

포함 다형성이 가능하기 위해서는 업캐스팅동적 메서드 탐색이 가능해야한다.

 

여기서 업캐스팅이란 자식 타입의 인스턴스를 부모 타입에 할당이 가능하다는 의미이다.

 

만약 업캐스팅으로 인해 부모 타입에 자식 타입의 인스턴스가 할당되었다고 가정하자.

추상화에 의해 메시지가 전달되면 해당 메시지를 어디로 보내야 할지를 결정해야한다.

 

처음 메시지를 전달받은 객체가 메시지를 수행할 수 있다면 수행하면 되지만, 수행할 수 없다면, 상위 객체에게 메시지 수행을 위임해야한다.

 

이를 구현하기 위해 특정한 로직이 필요할 것 같지만, 상속을 사용하기만 하면 메시지 위임 기능이 자동으로 수행된다.

그렇다면 상속은 어떻게 동작하기 때문에 자동 메시지 위임을 제공해 주는 것일까?

 


상속

상속을 사용하면 자식 객체에서 부모 객체의 private을 제외한 모든 필드와 메서드에 접근할 수 있다.

이때 데이터와 행동이 상속되는 구조가 다르기 때문에 이를 나누어 정리하였다.

나는 자바언어의 경우에 대한 자료를 찾아보며 내용을 작성하였다.

 

 

상속의 동작 원리

필드와 같은 데이터는 자식 인스턴스를 생성했을 때 부모 클래스의 필드를 모두 포함하여 힙 영역에 저장된다.

각 인스턴스마다 상태를 뜻하는 데이터는 모두 다를 수 있기 때문에 이를 다른 공간에 저장할 수 밖에 없다.

 

반면 메서드의 경우에는 같은 타입의 객체라면 같은 행동을 공유한다.

따라서 중복 저장하여 메모리를 많이 사용할 필요 없이, 클래스 로드 시 메타데이터에 포함되어 메서드 영역에 저장된다.

 

이렇게 메서드와 인스턴스 분리된 영역에 저장되기 때문에 인스턴스는 타입에 대한 정보가 저장된 영역의 주소를 저장하는 class 포인터가 필요하다.

 

상속을 사용하는 경우에는 부모의 정보에도 접근하기 위해서 자식 클래스의 메타데이터 정보에는 parent 포인터를 두어 부모 클래스의 메타데이터에도 접근 가능하도록 설계해야한다.

이러한 구조에서 메시지가 전달 되었을 때 메시지를 수행할 메서드를 찾는 과정을 살펴보자.

 

1. 메시지를 수신하면 this라는 임시 변수가 생성되고, 이 변수는 메시지를 수신한 객체를 가리키도록 설정된다.

2. class 포인터를 통해 객체의 타입 정보에 접근하여 메시지를 수행할 수 있는 메서드가 존재하는지 확인한다.

  • 메서드가 존재하면 메서드를 실행하고 종료하고 this 변수를 소멸시킨다.
  • 메서드가 존재하지 않으면 parent 포인터를 따라 부모 타입에 접근하며 메시지 수행 가능 여부를 판단한다.

3. 최상위 클래스인 Object까지 이 과정을 반복한 후에도 메시지를 수행할 메서드를 찾지 못했다면 에러를 발생시킨다.

4. 탐색을 종료하고, this 변수를 소멸시킨다.

 

모든 메시지는 특정한 클래스가 아닌 this가 가리키는 특정한 클래스부터 메서드 탐색을 수행한다.

따라서 컴파일 시점에는 어떤 객체가 메시지를 수신할 지 알 수 없다.

 

이로 인해서 메서드 오버라이딩의 오작용 문제가 발생하기도 한다.

public class InstrumentHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCouunt++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

 

위 코드에서 addAll() 메시지를 전송받으면, super에 의해서 부모 객체의 addAll() 메서드가 호출된다.

만약 부모의 addAll() 메서드에서 add() 메서드를 호출한다면, this에 설정된 자식 클래스의 add() 메서드가 호출된다.

 

이로 인해 개발자가 예상한 방향과 다른 결과가 나오는 문제가 발생한다.

메서드 오버라이딩 오작용 문제는 이전에 다루었기 때문에 간략하게 소개하였다.

자세한 내용이 궁금하면 이전 글을 참조하면 좋을 것 같다.

https://gyuwon-tech.tistory.com/37

 

[스터디: 오브젝트] 상속과 코드 재사용

이번 장에서는 상속과 코드의 재사용에 대해 다룬다. 프로그래밍을 공부하면, 중복코드를 줄이라는 말을 쉽게 들을 수 있는데, 왜 중복코드를 줄여야 하는지에 대해서 깊게 생각하진 않았던 것

gyuwon-tech.tistory.com

 

그러면 왜 this라는 것을 둬서 이런 문제를 발생시키는 것일까?

 

this를 사용하면, 런타임이 되어야만 메시지를 수행할 구현체가 결정된다.

이러한 특징으로 인해 더 유연한 설계가 가능하다.

 

this와 함께 비교되는 super의 경우에는 조금 다르다.

 

super 키워드

책을 읽기전에 super 키워드는 자식 객체에서 부모 객체의 메서드를 호출하는 역할을 수행한다고 알고 있었다.

이 말은 완전히 틀린 것은 아니지만, 경우에 따라서 틀린 정의가 될 수도 있다.

 

super의 정확한 정의는 호출한 자식 클래스로부터 메서드 탐색을 시작하라는 의미이다.

만약 부모 객체에 메시지를 수행할 수 있는 메서드가 존재하지 않는다면, 다음 부모 객체를 탐색하며 메시지를 수행할 객체를 찾는다.

 

이 부분까지 읽고 this 키워드와 어떤 점이 다른지 고민해 보았다.

this 키워드는 어떤 객체가 메시지를 수행하는지를 컴파일 타임에 알 수 없다.

이러한 특징으로 인해  할 수 있다.

 

반면 super 키워드는 메시지를 수행하는 객체가 누구인지 컴파일 타임에 알 수 있다.

상위 클래스 중 메시지에 응답할 수 있는 클래스가 바로 메시지를 수신하는 객체인 것이다.

 

물론 앞선 장의 믹스인과 같이 런타임에 상속관계를 지정함으로써 super의 대상도 런타임에 바뀔 수 있자만, 자바와 같은 대부분의 객체지향 언어에서는 컴파일 타임에 그 구현이 결정되는 것이다.

 

따라서 super의 목적은 다형성을 구현하는 것이 아닌 중복을 제거하는 것에 더 가깝다고 생각했다.

상속을 통해서 다형성을 구현하는것과는 별개로 super는 중복을 제거하여 코드를 유지보수하기 쉽게 만들어준다.

 


정적 타입 언어와 동적 타입 언어

정적 타입 언어와 동적 타입 언어는 이러한 동적 메서드 탐색이 수행되는 시점이 다르다.

 

자바와 같은 정적 타입 언어의 경우에는 컴파일 시점에 컴파일러가 메서드 탐색을 통해서 메서드가 존재하는지 탐색한다.

만약 최상단의 클래스까지 탐색한 후에도 메시지를 수행할 객체를 찾지 못한 경우에는 컴파일 에러를 발생시켜 메시지에 응답할 수 없다고 알린다.

 

반면 동적 타입 언어는 컴파일 단계가 존재하지 않기 때문에 코드를 실행해야만 메시지 처리 가능 여부를 판단할 수 있다.

 

적절한 메서드를 찾지 못했을 때 예외를 발생시키는 것은 정적 타입 언어와 같지만, 동적 타입언어를 사용하면 예외가 발생하였을 때 추가적인 예외 처리가 가능하다는 점이 다르다.

 

동적 타입 언어는 실행 시점까지 상대 객체가 메시지를 수신할 수 있는지 여부를 모르기 때문에 다형성을 더욱 잘 구현할 수 있는 언어이다.

하지만 실행 시점이 되어서야 코드의 예외가 발생하는 부분을 알 수 있기 때문에 코드를 이해하고 수정하기 어렵게 만들며 디버깅을 복잡하게 만든다.

 

반면 정적 타입 언어는 동적 타입 언어에 비해 유연성은 부족하지만 더욱 안정적이다.

각각의 장단점이 있기 때문에 시스템의 특징에 따라서 적절한 언어를 선택하는 것이 중요하다고 생각한다.

 


객체지향 != 클래스

 

이전 장에서 상속은 코드 재사용을 위한 도구라고 정의했다.

상속은 코드 재사용을 위한 방법으로 활용 가능하지만, 코드 재사용을 위해서라면 상속보다는 합성을 사용하는 것이 더 유리하다.

 

상속의 진정한 목적은 서브타입의 구현이다.

상속을 통해서 서브타입을 구현함으로써, 타입 계층이 구성되고, 이로 인해 다형성을 실현할 수 있다.

 

상속을 사용하지 않고도 다형성을 구현하는 것이 가능하다.

상속이 다형성을 실현 할 수 있는 이유는 자동으로 동적 메서드 탐색 기능을 제공해 주기 떄문이다.

 

하지만 동적 메서드 탐색 기능은 클래스가 없는 환경에서도 충분히 구현 가능하다.

클래스의 개념이 없는 자바스크립트언어에서는 prototype의 개념을 사용하여 동적 메서드 탐색 기능을 구현할 수 있다.

 

다시 말해서 클래스의 개념이 없어도 다형성을 구현하여 객체지향설계를 할 수 있다는 것이다.

 

객체지향은 말 그대로 객체를 지향하는 것을 의미한다.

객체들의 역할, 책임, 협력을 통해서 프로그램을 변경에 용이하도록 설계하는 방법이다.

 

객체지향을 설계하기 위해 제공되는 요소로 클래스가 대표적이긴 하지만, 클래스가 없더라도 객체사이의 협력을 구축하고, 다형성을 구현하는 것이 가능하다.

 

다형성을 무엇으로 구현했느냐 보다는 상속과 객체의 메시지 위임이 다형성을 구현하기 위해 사용하는 메커니즘을 이해하는 것이 더 중요하다.

이 메커니즘을 이해하면 다형성과 객체지향의 개념에 대해 더욱 깊이 이해하고 이를 확장할 수 있는 능력이 길러질 것이다.

 


느낀점

이번 장을 읽으면서 상속의 동작 원리에 대해 깊이 이해할 수 있었다.

 

우리가 코드의 중복을 지양하는 것처럼, JVM도 메모리를 절약하고 복잡성을 줄이기 위해서 중복을 제거하며 데이터를 제거한다.

이번 장을 통해서 기존에 JVM의 메모리 구조에 대해서 받아들이기만 했던 것들이 왜 그렇게 저장하는지에 대해 고민하였다.

 

또한 일반적으로 상속을 사용할 때는 깊이가 2인 상속 관계를 사용하는 것이 대부분이였기 때문에 super는 부모 객체의 메서드를 호출한다고 생각하였는데 이 생각이 잘못된 생각라는 것도 알 수 있었다.

 

JVM의 메모리 구조나, super의 동작원리를 깊게 공부했더라면 알 수 있는 내용이었기 때문에 스스로를 반성하면서도, 책을 읽으면서 기존의 지식과 결합하여 잘못 생각했던 것을 바로 잡는 능력이 길러진 것 같아 뿌듯하기도 하였다.

 

앞으로도 공부를 할 때 왜라고 질문하며 깊이있는 공부를 하기 위해 노력할 것이다.