객체지향에서 서브타이핑과 상속의 차이, 메서드 오버라이딩 시 인자 타입의 변성으로 인해 발생하는 불건전성 문제를 C#, Eiffel, TypeScript, Swift 등의 예로 설명한다.
객체지향 언어에서 클래스 A를 작성한 뒤, 이를 확장하고 싶을 때는 서로 다른 두 가지 방식이 있다:
서브타이핑은 A의 인터페이스에 부합하는 클래스 B를 작성하는 것을 뜻하며, 여기에 B만의 새로운 메서드를 추가할 수도 있다. 리스코프 치환 원리1에 따르면, A가 기대되는 어떤 맥락에서도 A 대신 B를 제공할 수 있다.
상속은 클래스 B를 작성하여 A를 특정 용도에 맞게 특수화(specialise)하는 것을 뜻한다. 즉, A의 일부 동작을 재사용하고 필요하면 일부를 오버라이드한다.
둘은 비슷하다. 클래스 B가 A의 모든 기능을 재사용하면서 B만의 새 메서드를 추가한다면, B는 A를 상속하면서 동시에 A의 서브타입이 된다.
하지만 둘은 동일하지 않다. B가 동작 대부분을 A로부터 상속하되 단 하나의 메서드 m만 오버라이드한다고 하자. B가 A의 특수화 버전이라면, B의 m은 특수화된 입력을 요구할 수 있으며, A.m의 입력 타입의 특수화 버전만 받아들일 수도 있다. 반대로 B가 A의 서브타입이라면, A의 인터페이스에 부합하기 위해 B의 m은 A.m이 받아들이는 모든 입력을 받아들여야 한다. 따라서 B.m의 입력은 A.m의 입력에 대한 상위 타입(supertype) 이어야 한다.
많은 객체지향 언어는 상속과 서브타이핑을 서브클래싱(subclassing) 으로 혼동한다. 대표적인 예로 C#, Eiffel, TypeScript가 있으며, 이들은 서브클래스에서 오버라이드된 메서드를 타입체크하는 방식이 서로 다르다.
C#은 서브클래스의 메서드가 오버라이드된 메서드가 받아들이는 것과 정확히 동일한 타입을 받아들여야 한다고 요구한다. 이는 건전하지만, 서브타이핑과 특수화의 일부 용도를 다루기에는 불편해진다. Eiffel은 특수화를 선호하여, 서브클래스의 메서드가 오버라이드된 메서드가 받아들이는 것의 서브타입을 받아들여야 한다고 요구하는데, 이는 불건전하다2. TypeScript는 모호하여, 서브클래스의 메서드가 오버라이드된 메서드가 받아들이는 것의 상위 타입 또는 서브타입을 받아들이기만 하면 된다고 요구하는데, 이것 역시 불건전하다.
TypeScript와 Eiffel에서의 건전성 문제는 동일하다. A.m 메서드가 B.m에 의해 오버라이드될 수 있는데, B.m이 원래 타입의 서브타입만 받아들이도록 하자. B가 A의 서브타입으로 간주되므로 B는 A인 것처럼 사용될 수 있고, A를 통해 B.m 메서드를 호출할 때, B.m이 기대하는 서브타입이 아닌 인자를 전달할 수 있다.
interface Base {
name: string;
}
interface Sub {
name: string;
doStuff: () => number;
}
class A {
go(_ : Base) {}
}
class B extends A {
go(x : Sub) { x.doStuff(); }
}
let x : Base = { name: "x" };
let b = new B();
let a : A = b;
a.go(x); // crashes
-- Counterexample by W. R. Cook
class Base feature
base (n : Integer) : Integer
do Result := n * 2 end;
end
---
class Extra inherit Base feature
extra (n : Integer) : Integer
do Result := n * n end;
end
---
class P2 feature
get (arg : Base) : Integer
do Result := arg.base(1) end
end
---
class C2 inherit P2 redefine get end feature
get (arg : Extra) : Integer
do Result := arg.extra(2) end
end
---
local
a : Base
v : P2
b : C2
i : Integer
do
create a;
create b;
v := b;
i := v.get(a) -- crashes!
end
class type base = object
method name : string
end
class type sub = object
method name : string
method do_stuff : int
end
class a = object
method go (_ : base) = ()
end
class b = object (self : < go : sub -> int; .. >)
(* does not compile *)
inherit a
method! go (x : sub) = x#doStuff
end
class A {
public void go(Object arg) {}
}
class B : A {
// does not compile
public override void go(String arg) {}
}
TypeScript는 2.6 버전부터 strictFunctionTypes 옵션3을 지원하는데, 이는 일부 경우에 더 엄격한 서브타이핑 검사를 사용한다. 그러나 위의 반례는 strictFunctionTypes를 사용해도 여전히 허용된다.
이론적으로 Eiffel은 "시스템 유효성 검사(system validity check)"로 건전성을 회복하는데, 이는 Cook의 반례(이를 Eiffel 커뮤니티에서는 "catcalls"라고 부르며, "Changed Availability or Type"의 약자다) 같은 상황을 탐지하도록 설계된 전체 프로그램 데이터플로 분석이다. 하지만 이 검사는 상당히 까다롭고, 실제로 이를 구현한 Eiffel 컴파일러는 지금까지 없었던 것으로 보인다4.
같은 문제는 클래스 메서드 에서도 발생할 수 있다. 클래스 메서드는 특정 인스턴스가 아니라 클래스 자체와 연관된 메서드다. 클래스 메서드를 가진 언어에서는 클래스를 값으로 전달할 수 있으며, 런타임에 결정되는 적절한 클래스에 메서드 호출을 디스패치한다.
클래스 메서드가 서브클래스에서 오버라이드될 수 있을 때, 동일한 서브타이핑 vs. 상속 문제가 제기된다. 서브클래스는 인자 타입을 특수화할 수 있는가, 아니면 상위 타입을 받아들여야 하는가? 이와 관련된 건전성 문제(위와 유사한 문제)가 Swift에서 발생했다5:
// Counterexample by Ben Pious
class C<T> {
let t: T
init(t: T) {
self.t = t
type(of: self).f()(self)
}
class func f<U>() -> (U) -> () where U: C {
return { (u: U) in
print(u.t)
}
}
}
class E {
let g = "Expected to Print"
}
class D: C<E> {
override class func f<U>() -> (U) -> () where U: E {
return { (u: E) in
print(u.g)
}
}
}
let d = D(t: E()) // prints random garbage