[오브젝트] chap12을 읽고

우리의 결론은 코드 재사용을 목적으로 상속을 사용하면 변경하기 어렵고 유연하지 못한 설계에 이를 확률이 높아진다는 것이었다. 취약한 기반 클래스 문제가 그렇다.

따라서 우리는 상속을 사용하는 목적이 코드를 재사용함에 있어서는 안된다. 우리는 클라이언트 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위해 상속을 사용하여야 한다.

이번 장에서는 상속의 관점에서 다형성이 구현되는 기술적인 메커니즘을 살펴보기로 한다.

다형성의 종류

컴퓨터 과학에서의 다형성은 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의된다. 간단히 말하면 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이라고 할 수 있다.

객체지향 프로그래밍에서 사용되는 다형성은 다음과 같이 분류된다.

오버로딩 다형성

오버로딩 다형성은 일반적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리킨다. 메서드 오버로딩을 사용하면 유사한 작업을 수항하는 메서드의 이름을 통일할 수 있기 때문에 기억해야 하는 이름의 수를 극적으로 줄일 수 있다.

부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것도 메서드 오버로딩이라고 부른다.

강제 다형성

강제 다형성은 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식을 가리킨다. 예를 들어 JAVA+ 연산자가 int타입 사이에서 작동하는 방식과 String 사이에서 작동하는 방식이 다른 것 처럼 말이다.

매개변수 다형성

매개변수 다형성제네릭 프로그래밍과 관련이 높다. 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의ㅡ이 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식을 가리킨다.

포함 다형성

포함 다형성은 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. 서브타입 다형성이라고도 부른다. 포함 다형성은 객체지향 프로그래밍에서 가장 널리 알려진 형태의 다형성이기 때문에 특별한 언급 없이 다형성이라고 할 때는 포함 다형성을 의미하는 것이 일반적이다.

포함 다형성을 위한 전제조건은 자식 클래스가 부모 클래스의 서브타입이어야 한다는 것이다. 그리고 상속의 진정한 목적은 코드 재사용이 아니라 다형성을 위한 서브타입 계층을 구현하는 것이다.

포함 다형성을 위해 상속을 사용하는 가장 큰 이유는 상소깅 클래스들을 게층으로 쌓아 올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다. 객체가 메시지를 수신하면 객체지향 시스템은 메시지를 처리할 적절한 메서드를 상속 계층 안에서 탐색한다.

부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선숭위가 더 높다. 즉, 같은 메시지를 수신했을 때 부모클래스의 메서드가 아닌 자식 클래스의 메서드가 실행된다. 동일한 시그니처를 가진 자식 클래스의 메서드가 부모 클래스의 메서드를 가리게 된다. 이를 메서드 오버라이딩 이라고한다.

이번 장의 목표는 포함 다형성의 관점에서 런타임에 상속 계층 안에서 적절한 메서드를 선택하는 방법을 이해하는 것이다.

상속의 관점

객체지향 패러다임의 근간을 이류는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것이다. 따라서 객체지형 프로그램을 작성하기 위해서는 항상 데이터와 행동으라는 두 가지 관점을 함께 고려해야 한다.

상속도 데이터와 행동이라는 두가지 관점에서 상속을 볼 수 있다. 상속을 이용해 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다. 이것이 데이터 관점의 상속이다. 또함 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다. 이것이 행동 관점의 상속이다.

새로운 클래스를 자식 클래스로 정의하는 것만드로도 원래 클래스가 가지고 있는 데이터와 메서드를 새로운 클래스의 것으로 만들 수 있다.

데이터와 행동 모두의 관점에서 상속을 그림으로 표현한 그림이다. 실제 언어에 따라 메모리 구조는 다르겠지만 개념적으로 위와 같은 형태로 생겼다고 이해하면 될 것이다. 위의 그림을 참고하며 데이터와 행동 관점의 상속에 대해 더 알아보자.

데이터 관점의 상속

상속을 데이터 관점에서 바라볼 때는 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스가 포함되는 것으로 생각하는 것이 유용하다.

행동 관점의 상속

행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다.

언어의 종류와 언어가 정의하는 접근자에 따라 다른 경우가 있지만, 공통적으로는 부모 클래스의 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 클라이언트가 부모 클래스의 인스턴스에게 전송할 수 잇는 모든 메시지는 자식 클래스의 인스턴스에도 전송할 수 있다.

부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지는 과정이 실제로 클래스의 코드를 복사하는 것처럼 보일 수 있다. 하지만 실제로는 코드 복사는 일어나지 않는다. 실제로는 런타임에 시스템이 자식 클래스에 정의되지 않는 메서드가 있는 경우 이 메서드를 부모 클래스 안에서 탐색한다.

객체의 경우에는 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다.

인스턴스는 여러개 생성되지만 클래스는 한 번만 로드된다.

로드된 클래스들은 상속관계에 따라 parent 포인터로 연결되어 있다. 자식 클래스가 parent 포인터를 가지고 부모 클래스를 가리키고 있다.

인스턴스들은 자신의 클래스를 가리키는 class 포인터를 가지고 있다.

메시지를 수신한 인스턴스는 class 포인터로 연결된자신의 클래스에서 적절한 메서드가 존재하는지를 찾고, 만약 메서드가 존재하지 않으면 클래스의 parent 포인터를 따라 부로 클래스를 차례대로 훑어가며 적절한 메서드가 존재하는지 검색한다.

다형성과 상속의 메커니즘

  • 업캐스팅
  • 동적 메서드 검색
  • 동적 바인딩
  • self 참조
  • super 참조

위는 상속의 메커니즘을 이해하는 데 필요한 몇 가지 개념이다. 먼저 데이터 관점의 상속과, 행동 관점의 상속을 조금 더 알아보고 위 개념을 통해 상속의 내부 메커니즘뿐만 아니라 타입 계층을 기반으로 한 다형성의 동작 방식을 이해해보자.

같은 메시지, 다른 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
    Parent person;

    Client(Parent person) {
        this.person = person;
    }

    public void sayHello() {
        person.hello();
    }
}

Client c1 = new Client(new Parent());
c1.sayHello();
Client c2 = new Client(new Child());
c2.sayHello();

생성자의 인자 타입은 Parent로 선언되어 있지만 Child의 인스턴스를 전달하더라도 아무 문제 없이 실행된다는 사실을 알 수 있다. 동일한 객체 참조인 Parent에 대해 동일한 hello메시지를 전송하는 동일한 코드 안에서 서로 다은 클래스안에 구현된 메서드를 실행한다.

이처럼 작동할 수 있는 것은 업캐스팅동적 바인딩이라는 메커니즘이 작동하기 때문이다.

  • 업캐스팅 : 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능한 것
  • 동적 바인딩 : 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정되는 것이다. 이는 객체지향 시스템이 메시지를 처리할 적절한 메서드를 실행시점에 결정하기 때문이다.

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식의 퍼블릭 인터페이스에 합쳐지기 때문에 부모에게 전송할 수 있는 메시지를 자식에게도 전송할 수 있다.

컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다. 이런 특성을 활용할 수 있는 대표적인 두 가지가 대입문과 메서드의 파라미터 타입이다.

부모 클래스 타입으로 선언된 파라미터에 자식 클래스의 인스턴스를 전달하는 것도 대입문처럼 컴파일러가 허용한다. 하지만 반대의 경우에는 명시적인 타입캐스팅이 필요한데 이를 다운캐스팅이라고한다.

컴파일러의 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있기 때문에 부모 클래스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력하는 것이 가능하다. 현재 상속 계층에 포함되는 자식 뿐만 아니라 앞으로 추가될지도 모르는 자식 클래스들과도 협업이 가능하다. 자식 클래스는 부모 클래스의 모든 메시지를 이해할 수 있기 때문이다.

동적 바인딩

객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다. 전통적인 언어에서의 함수 호출과의 차이는 함수 호출 구문과 실제 실행될 코드를 연결하는 메커니즘에 차이가 있다.

함수 호출에서는 코드 상의 함수 호출이 항상 같은 코드를 실행시킨다. 이를 정적 바인딩, 초기 바인딩, 컴파일 타임 바인딩이라고 부른다.

객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. 코드 상에 존재하는 메시지 전송은 인스턴스 타입에 따라 어느 메서드가 호출될 지 동적으로 결정된다. 이런 방식을 동적 바인딩, 지연 바인딩이라고 부른다.

동적 메서드 탐색과 다형성

이제 우리는 실제로 동적 바인딩이 어떻게 일어나는지 알아보자. 어떤 규칙에 따라 메시지와 메서드를 런타임에 바인딩 할 수 있을까?

객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
  • 메서드를 찾지 못했다면 부모클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가면 계속된다.
  • 상속 계층의 가장최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.

메시지 탐색과 관련한 중요한 변수가 있다. self 참조이다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자송으로 생성한 후 메시지를 수신한 객체를 가리키도록설정한다. 동적 메서드 탐색을 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다.

객체지향 시스템은 앞서 설명한 class 포인터와 parent 포인터와 함께 self 참조를 조합해 메서드를 탐색한다. 화살표를 따라가면서 적절한 메서드를 찾지 못하면 다음 단계로 넘어간다.

메서드 탐색은 자식 클래스에서 부모 클래스의 방향으로 진행된다. 따라서 항상 자식 클래스의 메서드가 부모 클래스의 메서드보다 먼저 탐색되기 때문에 높은 우선 순위를 가진다.

동적 메서드 탐색은 자식 클래스에서 이해할 수 없는 메시지를 부모 클래스로 처리를 위임하는 자동적인 메시지 위임을 통해 이뤄진다. 또한 메시지를 탐색하는 경로는 런타임에 메시지 수신 시 self 참조를 통해 시작되기 때문에 동적인 문맥을 사용한다. 여기서 가장 중요한 것이 시작점인 self 참조이다.

위의 그림을 기반으로 자동적인 메시지 위임과 동적인 문맥을 설명해 보겠다.

자동적인 메시지 위임

자식 클래스에서 부모 클래스로 향하는 자동적인 메시지 위임이 실제로 오버라이딩과 오버로딩에서 어떻게 작동하는지 알아보자.

메서드 오버라이딩을 알아보자.

1
2
Lecture lecture = new GradeLecture(...);
lecture.evaluate();

위와 같은 코드가 있다고 하자. evaluate 메서드는 동일한 시그니처로 GradeLectureLecture에 모두 존해 한다. 동적 메서드 탐색은 self 참조가 가리키는 객체의 클래스로부터 시작된다.

동적 메서드 탐색이 자식 클래스에서 부모 클래스 방향으로 이루어 지기 때문에 자식 클래스에 메서드가 존재한다면 부모 클래스의 메서드를 감추는 것처럼 보이게 된다.

메서드 오버로딩의 작동방식을 알아보자.

메서드 탐색은 메서드의 시그니처로 이루어진다. 따라서 클라이언트가 average()를 호출했다면 GradeLectureaverage(gradeName) 시그니처가 다르기 때문에 해당 클래스를 넘어 부모 클래스로 메서트 탐색이 위임된다.

동적인 문맥

self 참조가 메서드 탐색의 시작점이다. 동일한 코드라고 하더라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.

중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀐다는 것이다. 즉 동적으로 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다는 것이다. self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 싱핼될 문맥을 동적으로 바꿀 수 있다.

self 참조가 동적 문맥을 결정한다는 사실은 어떤 메서드가 실행될지 예상하기 어렵게 만든다. 대표적인 예는 self 전송이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Parent {
    public String a() {
        return "a call" + b();
    }

    public String b() {
        return "Parent's b"
    }
}

public class Child extend {

    @Override
    public String b() {
        return "Child's b"
    }
}

예를 들어 아래와 같은 클래스가 있다고 상상해보자. 여기서 아래와 같은 코드를 실행하면 출력값은 무엇이 될까?

1
2
Parent person = new Child();
person.a();

Child에게 a 메시지를 전송하면 self 참조는 Child의 인스턴스를 가리키도록 설정된다. Child 클래스에는 a 메시지를 처리할 수 없기 때문에 자동적인 메시지 위임이 일어나 Parent 클래스의 a 메서드를 실행시킬 것이다.

Parenta 메서드를 실행하는 중에 b 메시지를 호출하는 구문과 마주치게 된다. 이제 메서드 탐색이 다시 이루어 진다. 메서드 탐색은 self 참조가 가리키는 객체에서부터 다시 일어나기 시작한다. 즉, Child에서 부터 b 메시지를 이해할 수 있는지 찾는 것이다. 따라서 위의 코드의 출력 값은 "a call Child's b"가 된다.

이를 self 전송이라고 한다. 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 클래스로 이동시킨다. 이로 인해 상속 계층이 깊은 경우에 실제로 실행되는 메서드를 이해하기 위해 전체 코드를 훑어야 하는 이해하기 어려운 코드가 생성될 수 있다. 결과적으로 self 전송이 깊은 상속 계층과 만나게 되면 극단적으로 이해하기 어려운 코드가 만들어 질 수 있다.

이해할 수 없는 메시지

만약 상속 계층을 다 뒤져도 메시지를 처리할 수 있는 메서드가 존재하지 않는다면 어떨까? 이해할 수 없는 메시지를 처리하는 방법은 프로그래밍 언어가 정적 타입에 속하는지, 동적 타입에 속하는지에 따라 달라진다.

정적 타입 언어와 이해할 수 없는 메시지

정적 타입 언어에서는 코드를 컴파일 할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다. 결국 이해할 수 없는 메시지를 발견할 경우 컴파일 에러를 발생시킨다. 자바의 경우가 정적 타입 언어이다.

동적 타입 언어와 이해할 수 없는 메시지

동적 타입 언어에서는 컴파일 단계가 존재하지 않기 때문에 실제로 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다. 최상위 클래스까지 메서드를 탐색한 후에 메시지를 처리할 수 없음을 발견하면 self 참조가 가리키는 현재 객체에게 메시지를 이해할 수 없다는 메시지를 전달하거나 예외를 던지게 된다. 동적 타입 언어에는 스몰토크나 루비와 같은 언어가 있다.

이해할 수 없다는 메시지를 전달받거나 예외가 던져지는 경우에 동적 타입 언어에서 문제를 해결할 수 있는 방법은 해당 메시지나 예외를 처리할 수 있는 메서드를 구현하는 것이다.

이는 객체지향의 이상에 좀 더 가깝다. 객체지향은 메시지를 기반으로 협력하는 지율적인 객체의 모임을 만드는 것이기 때문이다. 이는 유연하지만 코드를 이해하기 어렵게 만들고 디버깅 과정을 복잡하게 만들기도 한다.

self 대 super

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다. 대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공한다.

super 참조를 이용해 부모 클래스에게 메시지를 전송할 수 있다. ‘전송’의 의미는 ‘지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하라’라는 의미이다. 이처럼 super 참조를 통해 메시지를 전송하는 것은 마치 부모 클래스의 인스턴스에게 메시지를 전송하는 것처럼 보이기 때문에 이를 super 전송이라고 부른다.

self 전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만 super 전송의 경우에는 컴파일 시점에 미리 결정해 놓을 수 있다.

상속 대 위임

지금까지 살펴본 내용은 동일한 타입의 객체 참조에게 동일한 메시지를 전송하더라고 self 참조가 가리키는 객체의 클래스가 무성시냐에 따라 메서드 탐색을 위한 문맥이 달라진다는 것이다. 그러면 새로운 시작에서 상속을 바라볼 수 있는다. 자식 클래스에서 부모 클래스로 self 참조를 전달하는 메커니즘으로 상속을 바라볼 수 있다.

위임과 self 참조

self 참조는 항상 메시지를 수신한 객체를 가리킨다. 따라서 메서드 탐색 중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를 공유하는 것으로 봐도 무방하다.

즉 메서드 호출 시, self 참조가 항상 전달되고 self 참조가 가리키는 클래스에 메서드를 찾을 수 없으면 부모 클래스로의 위임이 일어난다. 자바의 경우 메서드 파라미터에 self 참조를 추가하고 메서드 호출 시 this를 생략하지 않으면 self 참조를 통한 위임이 어떻게 일어나는지 쉽게 추측할 수 있다.

책에 등장하는 포워딩과 프로토타입 개념은 생략하도록 하겠다.

태그:

카테고리:

업데이트:

댓글남기기