다형성
객체지향 프로그래밍에서 다형성은 유니버셜 다형성과 임시 다형성으로 분류할 수 있다. 유니버셜 다형성은 다시 매개변수 다형성과 포함 다형성으로 분류할 수 있고 임시 다형성은 오버로딩 다형성과 강제 다형성으로 분류할 수 있다.
오버로딩 다형성은 하나의 클래스 안에 동일한 이름의 메소드가 존재하는 경우를 가리켜 말한다.
강제 다형성은 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식을 말한다. C++에서의 operator+
같은 메소드가 이에 해당한다.
매개변수 다형성은 재네릭 프로그래밍과 관련이 높다. 제네릭을 통해 다양한 임의 타입을 받도록 허용하고 실제 인스턴스를 생성하는 시점에 임의 타입을 구체 타입으로 지정할 수 있게 한다.
포함 다형성은 메세지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 것을 말한다. 이를 서브타입 다형성이라고도 하는데 객체지향에서 가장 널리 알려진 다형성이다.
상속의 이해
상속의 목적을 코드 재사용의 관점으로 보면 안된다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층 구축을 위한 것이다. 상속은 다시 나뉘어서 두 관점으로 볼 수 있다. 하나는 데이터 관점에서의 상속이다. 데이터 관점에서의 상속은 자식 클래스가 부모 클래스를 포함한다고 생각하면 편하다. 다른 하나는 행동 관점의 상속이다. 행동 관점에서의 상속은 부모 클래스가 정의한 일부 메소드를 자식 클래스의 메소드로 포함시키는 것이다. 엄밀히 말하자면 포함이 아닌 타입 계층 구조에서 어떤 방식으로 수행하고자 하는 메소드를 찾아야 하는지에 대한 지침을 알려주는 것이다.
- 업캐스팅 : 부모 클래스 타입으로 선언된 변수에 자식 클래스 인스턴스를 할당하는 것
- 동적 바인딩 : 선언된 변수의 타입이 아닌 메세지를 수신하는 객체의 타입에 따라 실행되는 메소드가 결정된다. 이는 객체지향 시스템이 처리할 적절한 메소드를 컴파일 시점이 아닌 런타임 시점에 결정하기 때문인데 이를 동적 바인딩이라 한다.
self 참조
객체지향 시스템은 다음 규칙에 따라 동적으로 메소드를 탐색한다.
- 메세지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메소드가 존재하는지 검사한다. 존재하면 메소드를 실행하고 탐색을 종료한다.
- 메소드를 찾지 못했다면 부모 클래스에서 메소드 탐색을 계속한다. 이 과정을 적합한 메소드를 찾을 때 까지 상속 계층을 따라 올라간다.
- 상속 계층의 가장 최상위 클래스에 이르렀는데도 찾지 못하면 예외를 발생시키고 종료한다.
이 과정에서 동적 메소드 탐색은 두 가지 원리로 진행된다. 첫째는 자동적인 메세지 위임이다. 자식 클래스는 자신이 이해할 수 없는 메세지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다. 이 위임은 상속 계층을 따라 자동으로 이루어 진다. 둘째는 메서드 탐색을 위해 동적인 문맥을 사용하는 것 이다. 메세지를 수신했을 때 실제로 어떤 메소드를 실행할지를 결정하는 것은 컴파일 시점이 아닌 런타임 시점이며, 메소드 탐색 경로는 self 참조를 이용해서 결정한다.
자동적인 메세지 위임에서는 두 측면으로 나눠서 볼 수 있다. 하나는 메소드 오버라이딩이다. 대부분의 객체 지향에서 제공하는 메커니즘으로 볼 수 있다. self 참조가 자식 클래스부터 탐색을 시작하기 때문에 메소드 시그니처가 같은 부모 클래스의 메소드를 감추는 효과가 있다. 메소드 오버로딩은 언어마다 조금씩 다르다. self참조를 통해 자기 자신부터 탐색하며 상위 계층으로 올라가면서 메소드 시그니처가 같은 메소드를 탐색한다. 하지만 C++같은 언어는 클래스 내에 메소드 오버로딩은 허용하지만 상속 계층간 오버로딩은 허용하지 않는다. 이는 상속 계층 내에서 동일한 이름을 가진 메소드가 공존해서 발생시키는 혼란을 방지하기 위해 부모 클래스에 선언된 이름이 동일한 메소드는 전부 숨겨지게 된다. 이를 이름 숨기기(name hiding)라고 한다.
동적인 문맥은 탐색을 위해 self 참조를 이용한다고 설명했다. 이 의미는 메세지를 수신한 객체가 무엇이냐에 따라 메소드 탐색을 위한 문맥이 동적으로 바뀐다는 의미이다. 가령 다음과 같은 코드가 있다고 가정하자.
1 | public class Parent { |
1 | Parent child = new Child(); |
위 코드에서 두 메소드의 foo 실행결과는 다르다. 엄밀히 말해서 Parent의 foo() 내에서 호출하는 this.bar()의 대상이 다르다. self 참조를 사용하기 때문에 현재 클래스의 bar 메소드가 아닌, self 참조가 되고있는 대상의 클래스부터 다시 탐색을 상속 계층 구조로 이어나가게 된다.
그렇다면 메소드를 찾지 못했을때는 어떻게 처리가 될까? 이는 동적 타입 언어와 정적 타입 언어에 따라 달라진다. Java같은 동적 타입 언어에서는 메소드를 상속 계층을 따라가며 탐색을 하고 찾지 못한다면 컴파일 에러를 발생시킨다. 동적 타입 언어 역시 같은 방식으로 탐색을 하지만 컴파일 단계가 존재하지 않기 때문에 실행 시점까지 처리 가능 여부를 판단할 수 없다. 루비의 경우에서는 실행할 메세지를 찾지 못하면 method_missing
메세지를 전송하게 된다. 그리고 다시 method_missing
메세지를 계층 구조로 탐색하여 최상이 클래스인 Object 에서 NoMethodError 에러
를 던진다.
self와 super
self는 자기 참조라고 말할 수 있다. 그러면 super가 가지는 의미는 무엇일까. super 참조의 의미는 지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요이다. super 참조 또한 자동적인 메세지 위임이 적용되며 단순히 부모 클래스가 아니더라도 조상 클래스에 해당 메소드가 존재한다면 실행된다. 즉 이는 super라는 클래스 내부 참조 변수를 통해 부모 클래스에게 메세지를 전송하는 것이다. self를 통한 전송은 실제 사용되는 객체 부터 시작해 탐색이 이루어진다. super는 이런 맥락과는 다르다. super는 항상 해당 클래스의 부모 클래스에서부터 메소드 탐색을 시작한다. 즉, super를 통해 메세지를 전송하는 것은 컴파일 시점에 미리 결정해 놓을 수 있다.