상속(Inheritance)

From 2Computing

Jump to: navigation, search

상속(Inheritance)은 시스템을 객체지향적이게 하는 중요한 메커니즘 중에 하나이지만, 보통의 경우에 객체지향의 상속은 코드 수준의 재사용성을 위한 메커니즘 정도로 설명되고 이해되어 있는 경향이 있는 것 같다. 하지만, 재사용성을 위한 메커니즘뿐만 아니라, 상속은 추상화를 위한 메커니즘으로서의 중요한 역할도 하고 있다. 본 주제에서는 상속의 기본적인 특징과 함께 상속과 연관 지어 생각해 볼 중요한 개념들에 대해서 살펴 보도록 하겠다.


Contents

상속의 기본적인 개념

상속(Inheritance)의 기본적인 아이디어는 단순하다.

상속은 이미 정의되어 있는 객체의 정의에 기반 하여 새로운 객체를 정의할 수 있도록 해 주는 메커니즘이다.

Java, C# 언어처럼 클래스를 기반으로 하는 객체지향 언어에서의 상속은 이미 정의되어 있는 클래스를 기반으로 새로운 클래스를 정의하는 것을 의미한다. 즉, 새로 정의할 클래스에는 이미 정의되어 있는 클래스에 없는 멤버 필드나 메소드만을 정의하면 된다는 것이다.

구체적인 예를 들어보자(이 장에서 사용된 Window 클래스 관련 예는 Antero TaivalsaariOn the notion of Inheritance 논문에 실려 있는 것을 수정한 것이다). 화면에 윈도우를 그리기 위하여 윈도우 클래스를 그림 3-1과 같이 간단하게 정의하였다. Window 클래스는 멤버 필드로 윈도우 프레임의 폭(width)과 높이(height)의 값을 가지는 변수와 프레임에 표시될 내용(content)을 담는 변수를 가지고 있다. 멤버 메소드로는 프레임을 그리는 drawFrame과 그려진 프레임에 내용을 그리는 drawContent을 가진다.

그림 3-1. Window 클래스
Enlarge
그림 3-1. Window 클래스
그림 3-2. Window 클래스를 기반으로 정의된 TitleWindow 클래스
Enlarge
그림 3-2. Window 클래스를 기반으로 정의된 TitleWindow 클래스


이렇게 정의된 Window 클래스는 일반적으로 윈도우에서 볼 수 있는 타이틀을 표시하지 않는다. 화면에 표시될 윈도우에 타이틀을 표시하기 위하여 Window 클래스를 수정할 수도 있다. 그러나, 경우에 따라서는 타이틀이 없는 윈도우를 필요로 할 경우도 많이 있다. 따라서, 타이틀을 표시할 수 있는 새로운 윈도우를 정의하기로 결정하였다고 하자. 타이틀을 표시하는 윈도우는 이미 정의된 Window 클래스와 비교하여 타이틀을 표시한다는 차이밖에는 없다. 이런 경우에 상속 메커니즘을 이용하면 이미 정의되어 있는 Window 클래스로부터 추가되는 멤버 필드와 메소드만을 추가하여 새로운 클래스를 정의할 수 있다. 그림 3-2는 상속 메커니즘을 이용하여 이미 정의되어 있는 Window 클래스로부터 타이틀을 표시할 수 있는 새로운 TitleWindow 클래스를 정의하는 모습을 보여주고 있다.



그림 3-3. 메소드 재정의가 지원되지 않는 경우의 메소드 정의– drawFrameWithName()
Enlarge
그림 3-3. 메소드 재정의가 지원되지 않는 경우의 메소드 정의– drawFrameWithName()

새롭게 정의된 TitleWindow 클래스는 상속의 개념에 의하여 이미 정의되어 있는 Window 클래스의 모든 멤버 필드와 메소드를 상속 받게 되므로 TitleWindow 클래스에서는 Window 클래스 정의에서 고려되지 않았던 타이틀을 표시하는 부분만을 첨가하면 된다. 이런 이유로 그림 3-2의 TitleWindow 클래스에는 (Window 클래스에는 정의되어 있지 않은) 타이틀을 위한 title 변수를 포함시켰다. TitleWindow 클래스에는 Window 클래스에 이미 정의되어 있는 drawFrame() 메소드가 재정의되어 있는 것을 볼 수 있다. 이렇게 동일한 메소드를 상속 받는 클래스에서 재정의한 이유는 Window의 프레임을 화면에 표시하는 것과는 다르게 TitleWindow의 프레임에는 타이틀 영역이 포함되어 있기 때문에 그 의미의 차이를 보이게 되므로 Window 클래스에서 정의된 drawFrame() 메소드를 그대로 사용할 수가 없는 것이다. 즉, 화면에 그려질 윈도우 프레임에 타이틀을 넣을 수 있는 새로운 drawFrame() 메소드가 필요하기 때문에, TitleWindow에서 Window의 drawFrame() 메소드를 새롭게 정의한 것이다. 이렇게 상속을 하는 클래스에서 이미 정의되어 있는 클래스의 메소드와 동일한 이름을 유지한채 그 내용을 새롭게 정의하는 것을 ‘메소드 재정의(Override)’라고 하며, 이 것은 여기서의 예에서 볼 수 있는 것처럼 상속 메커니즘을 의미상 좀 더 유연하게 해 주는 것과 동시에 나중에 자세히 살펴볼 객체지향의 다형성(Polymorphsm)을 가능하게 해 주는 중요한 요소 중에 하나가 된다. 만약 상속을 지원하는 시스템에서 메소드 재정의가 지원되지 않는다면, 그 상속은 절름발이 상속밖에 안 될 것이다. 그 이유는 메소드 재정의가 불가능 하므로, 상속을 하는 클래스에서 동일한 문맥의 메소드(drawFrame())를 다른 이름(예를 들면, drawFrame WithName()같은)으로 정의해야 할 것이기 때문이다(그림 3-3). 메소드 재정의에 대한 부분은 따로 자세히 알아보도록 하겠다.


상속과 관련된 몇 가지 객체지향의 용어가 있다. 클래스를 기반으로 하는 Java, C#과 같은 객체지향 프로그래밍 언어에서는 이미 정의되어 있는 Window 클래스를 슈퍼클래스(Superclass) 라고 하고, Window 클래스로부터 상속을 하는 TitleWindow 클래스를 서브클래스(Subclass) 라고 한다. 또 다른 용어로는 슈퍼클래스와 서브클래스에 해당하는 용어로 부모(Parent)와 자식(Child), 또는 Immediate ancestor, Immediate descendant라는 용어가 사용되고 있다. 본 책에서는 상속 관계에 있는 상위 클래스와 하위 클래스를 의미하는 용어로 슈퍼클래스와 서브클래스를 사용한다.


상속이 주는 이점

그럼 프로그래밍 언어에 상속의 개념을 도입함으로써 얻는 장점은 도대체 무었일까? 앞에서 들었던 예를 가지고 생각해 보자. 상속이 지원되지 않는 경우에 타이틀을 표시하는 윈도우를 화면에 그리기 위해서는 기본적으로 두 가지 방법을 생각해 볼 수 있을 것이다. 첫 번째 방법은 Window 클래스를 수정하여 타이들을 표시하는 윈도우 객체도 생성 할 수 있도록 하는 것이다. 이렇게 함으로써 하나의 클래스로 상황에 따라서, 타이틀을 표시하는 윈도우 객체를 생성하기도 하고 타이틀이 없는 윈도우 객체를 생성하게 하는 것이다. 두 번째 방법은 타이틀을 표시하는 새로운 클래스 TitleWindow를 아예 만드는 것이다. 이렇게 함으로써, 타이틀이 필요없는 윈도우가 필요할 때는 Window 클래스로 객체를 생성하고, 타이틀이 필요한 윈도우가 필요할 경우에는 TitleWindow 클래스로 객체를 생성하는 것이다. 이 곳에서 제시된 조그마한 예에서는 두 가지 방법 모두 별로 문제될 것이 없지만, 큰 소프트웨어 시스템의 경우에는 이 두 가지 방법 모두 만족스러운 해결책이 되지 못한다. 그 이유는 다음과 같다.


  • 첫 번째 방법의 문제점 :
    이 방법의 문제점은 기존의 소스를 수정하는 것에 있다. 규모가 큰 소프트웨어 시스템의 경우에는 클래스 하나의 소스를 고치는 데도 아주 신중해야 한다. 그 이유는 앞에서 누누이 말했듯이 객체지향 시스템은 클래스와 클래스 사이, 클래스와 객체 사이, 객체와 객체 사이에 일련의 관계를 맺어 있는 시스템이다. 그러므로, 소스를 직접 고쳐서 문제를 해결하는 것은 개발자가 클래스 수준이 아닌 소스 수준의 저 수준에서 시스템을 이해하여 수정해야 하므로 시간도 걸리고, 잘 못하면 수정하는 클래스와 관련이 있는 다른 클래스와 객체에 영향을 미칠 수 있는 큰 위험성을 가지고 있다.
  • 두 번째 방법의 문제점 :
    이 방법을 잘 관찰해 보면 기존의 클래스를 복사(copy)해서 수정(modify)하는 과정을 거치고 있음을 볼 수 있다. 따라서, 이 방법을 ‘copy-and-modify’구조(scheme)를 따르는 방법이라고도 알려져 있다. 따라서, 이 방법의 문제점은 이미 존재하는 클래스의 수정되는 부분의 코드 양이 클래스의 규모에 비하여 상대적으로 적은 부분일 가능성이 많다는 것과 이미 존재하는 클래스와 새롭게 정의되는 클래스 사이의 관계가 무시되는 것에 있다.


상속은 위의 두 가지 문제점을 모두 해결해 줄 수 있는 메커니즘이다. 앞에서의 예를 통해서 생각해 보면 다음과 같이 해결되는 것을 생각해 볼 수 있다.


  • 첫 번째 문제점에 대한 해결책으로서의 상속 :
    타이틀을 표시하는 윈도우를 위한 객체를 만들기 위해서 프로그래머는 Window 클래스를 소스 수준에서 깊이 이해할 필요가 없다. Window 클래스의 멤버 필드와 함수 수준에서 그 의미를 파악한 후에 Window 클래스를 상속하는 TitleWindow 클래스를 만들고 필요한 멤버 필드인 title과 의미가 달라진 수정된 drawFrame 멤버 함수만 재정의하는 것으로 마무리 지었다. 따라서, Window 클래스가 기존의 다른 클래스나 객체와 맺고 있었던 관계에 전혀 영향을 미치지 않고, 새로운 기능을 기존의 존재하는 시스템에 구현해 넣는 것도 빠르게 이루어질 수가 있으므로 첫 번째 문제점을 해결하고 있다.
  • 두 번째 문제점에 대한 해결책으로서의 상속 :
    프로그래머는 타이틀을 표시하는 윈도우 객체에 대한 클래스를 새롭게 정의하기 위해서 한 일은 기존의 Window 클래스에 없는 title 변수와 타이틀을 그릴 수 있게 재정의한 drawFrame() 함수가 전부이다. 윈도우를 화면에 그리기 위해서 타이틀을 제외한 나머지 부분에 대한 코드는 Window 클래스의 것을 그대로 사용하였다. 이렇게 함으로써 두 번째 방법이 가지는 동일 코드 중복에 따른 비합리성과 의미상 서로 연결성이 있는 두 클래스간의 관계를 부여하지 못하는 문제점을 해결하고 있다.


위의 상속의 해결방안을 이야기하면서 들었던 예를 구현 관점에서 자세히 살펴보면, 상속은 기존의 클래스(Window)로부터 이미 존재하는 코드를 그대로 사용하거나 또는 필요에 의해서 수정하기도 하고, 새로운 코드가 더해지기도 하는 것으로 볼 수 있다. 상속이 여러 번 이루어지게 되면, 최상위의 클래스는 상속을 통하여 점진적으로 수정되어져 나가게 되는 것을 생각해 볼 수 있다. 이런 관점에서 상속을 다음과 같이 정의하기도 한다.

Inheritance is an incremental modification mechanism.
상속은 코드의 점진적인 수정이 가능하게 하는 메커니즘이다.

사실 위의 정의는 불완전하다. 앞으로의 이야기 전개를 통해서 보충 시켜 나가도록 하겠다.



상속의 구현

C#

C#은 상속을 지원하는 객체지향 언어이며, 클래스간 상속을 위하여 “:” 연산자를 제공하고 있다. 아래의 소스 코드에서 SubClass는 SuperClass로부터 상속 메커니즘을 통하여 정의 되고 있는 모습을 보여 준다.

class SuperClass { . . . }
class SubClass : SuperClass { . . . }

또한, C#에서는 메소드 재정의와 관련되어 virtual과 override 예약어를 마련해 놓고 있다. 프로그래머가 상속의 관계에 있는 두 클래스에서 상위 클래스에 있는 메소드 중에서 재정의 될 수 있는 메소드를 virtual로 정의하고 하위 클래스에서 상위 클래스의 virtual로 정의되어 있는 메소드를 override 예약어로 재정의 하는 것이다. 아래 소스 코드에서 SubClass는 상속 관계에 있는 SuperClass에 virtual로 정의되어 있는 method()를 SubClass에서 override 예약어를 통하여 재정의하고 있는 모습을 보여 준다.

class SuperClass {
   . . .
   public virtual void method() { . . . }
   . . .
}
class SubClass : SuperClass {
   . . .
   // 슈퍼클래스의 virtual로 정의되어 있는 메소드를 재정의한다.
   public override void method() { . . . }
   . . .
}

앞에서 사용했던 Window와 TitleWindow 클래스의 예를 가지고 C#의 상속과 메소드 재정의가 어떤 모습으로 나타나는지 살펴 보도록 하겠다. 여기서는 주제에 충실하기 위하여 화면에 실재 윈도우를 표시하는 코드를 피하고 이를 간접적으로 표현하는 코드로 구현하였다. 다음 소스 코드를 보자.

class Window {
   private float width;
   private float height;
   private Point pivot;
   private string content;

   public Window(Point _pivot, float _width, float _height, string _content) {
       pivot = _pivot;
       width = _width;
       height = _height;
       content = _content;
   }

   // virtual 메소드
   public virtual void drawFrame() {
       Console.WriteLine(“Frame has been drawn”);
       Console.WriteLine(“width : {0}”, width);
       Console.WriteLine(“height : {0}”, height);
       …..
   }
   // non-virtual 메소드
   public void drawContent() {
       Console.WriteLine(“content : {0}”, content);
   }
}

// “:” 연산자를 이용하여 Window 클래스로부터 상속
class TitleWindow : Window {
   // 타이틀을 위한 새로운 변수 선언
   private string title;
    
   public TitleWindow(Point _pivot, float _width, float _height, string _content , string _title) {
       pivot = _pivot;
       width = _width;
       height = _height;
       // 새로 첨가된 코드
       title = _titile;
   }

   // override 예약어로 virtual 메소드 재정의
   public override void drawFrame() {
       Console.WriteLine(“Frame has been drawn”);
       Console.WriteLine(“width : {0}”, width);
       Console.WriteLine(“height : {0}”, height);
       // 새롭게 첨가된 타이틀 처리 코드
       Console.WriteLine(“Drawing Title : {0}”, title);
   }
}

먼저 TitleWinodw 클래스는 ‘:’ 예약어를 이용하여 Window 클래스로부터 상속 메커니즘을 통하여 정의되어 있다. 따라서, TitleWindow는 Window 클래스에 정의되어 있는 멤버 필드와 메소드를 그대로 상속 받고 있다. 하지만, 메소드를 상속하는데 있어서 일부 메소드에 대해서는 재정의가 이루어지고 있다. Window 클래스는 virtual modifier가 붙은 메소드와 그렇지 않은 메소드로 구분되어 진다. 여기서 일반적으로 virtual modifier가 붙은 메소드를 ‘virtual 메소드’라 하고 그렇지 않은 메소드를 ‘non-virtual 메소드’로 구분한다. non-virtual 메소드는 해당 메소드가 정의되어 있는 클래스나 이 클래스를 상위 클래스로 두고 상속하는 클래스에서나 같은 내용으로 변화 없이 같은 정의 내용이 적용되는데 반해서, virtual 메소드는 그 예약어의 단어가 의미하듯이 상황에 따라서 그 내용이 달리 적용될 수 있는 메소드라는 것을 명시하고 있는 것이다. 예에서 Window 클래스의 virtual 메소드인 drawFrame()은 TitleWindow에서 재정의되어 다른 내용으로 사용되는 반면에 Window의 non-virtual 메소드인 drawContent()는 TitleWindow에서도 동일한 내용으로 사용되는 것을 볼 수 있다. 이처럼 C#은 virtual과 override 예약어를 통하여 상속이 구현되기 위해서 기본적으로 필요하다고 생각되어지는 메소드 재정의(method overriding)를 지원하고 있다.



Java


.NET 프레임워크에서의 상속

상속을 빼고는 .NET 프레임워크를 말할 수 없다. .NET 프레임워크의 클래스 라이브러리 구조를 보면, 모든 클래스가 Object라는 하나의 클래스로부터 상속 받는 구조로 설계되어져 있다. 다시 말해서, Object 클래스는 .NET 클래스 라이브러리를 구성하는 최상위 슈퍼클래스인 셈이다(그림 3-4).


그림 3-4. System.Object 클래스
Enlarge
그림 3-4. System.Object 클래스


.NET 프레임워크를 기반으로 하는 프로그램의 모든 클래스는 ‘:’ 예약어를 사용하여 명시적으로 Object로부터 상속하도록 하지 않아도 암묵적으로 Object를 슈퍼클래스로 하는 클래스로 정의된다. Visual Studio.NET에 포함되어 있는 편집기를 이용하여 클래스를 정의해 보면 이 사실을 쉽게 발견할 수 있다. 그림 3-5는 Visual Studio.NET의 편집기를 이용하여 멤버 필드나 함수가 전혀 정의되어 있지 않은 클래스인 Foo를 정의한 후에 Foo 클래스 타입의 foo 객체를 생성하여 ‘.’를 타이핑하면 Foo 클래스에 정의되어 있지 않은 네 개의 메소드가 멤버 메소드로 선택될 수 있는 창이 뜨는 것을 확인 할 수 있다. 이 창에 표시된 네 개의 메소드는 모두 Object 클래스로부터 상속된 것들이다.

그림 3-5. System.Object로의 기본적인 상속
Enlarge
그림 3-5. System.Object로의 기본적인 상속


그럼, 왜 .NET 프레임워크에서는 모든 클래스가 Object 클래스로부터 상속되도록 설계하였을까? 이에 대한 해답은 상속에 대하여 좀더 구체적으로 생각해 보면서 나름대로 답을 찾아보도록 하자.



추상화를 위한 메커니즘으로서의 상속

지금까지 알아본 상속의 개념은 상속이 가지는 효용성의 일부분만 살펴본 것이다. 상속은 지금까지 생각해 본 것처럼 코드의 재사용(Code reuse)이라는 경제적인 측면을 가지고 있는 반면에, 문제를 추상화 하는 도구로서의 기능도 가지고 있는 객체지향의 중요한 메커니즘이다. 이 절에서는 문제를 추상화 하는 관점에서 상속의 또 다른 면을 알아보도록 하겠다.


일반화(Generalization)와 구체화(Specialization)

도형을 그릴 수 있는 응용프로그램을 만든다고 가정해 보자. 도형에는 삼각형, 사각형, 원 등의 여러 가지 종류가 있다. 이러한 도형을 추상화 하기 위하여 다음과 같이 원과 사각형을 각각 Circle 클래스와 Rectangle 클래스로 추상화 시켰다.

그림 3-7. Circle과 Rectangle 클래스
Enlarge
그림 3-7. Circle과 Rectangle 클래스


위와 같이 원과 사각형을 추상화 시켜놓고 보니, Circle 클래스와 Rectangle 클래스는 의미상 도형이라는 추상적인 대상으로 매우 밀접하게 연관되어 있으며, 결과적으로는 동일하게 다루어질 수 있는 부분들이 존재하게 되는 것을 볼 수 있다. 그림 3-7에서 보이듯이, Circle 클래스와 Rectangle 클래스는 실질적으로 처리하는 방식은 다르지만, 두 클래스 모두 도형 이라는 범주에 속하기 때문에 Draw, Rotate, Move라는 동일한 메시지를 받아서 처리할 수 있도록 추상화 되어 있다. 즉, 원과 사각형은 이들을 모두 포함하는 일반화된 개념인 ‘도형’을 통해서 밀접하게 연관되어 있음을 생각해 볼 수 있으며, 프로그래밍 언어에서 이러한 개념의 일반화를 모델링 할 수 있는 메커니즘을 제공하면, 문제를 추상화 하는 과정이 더욱 용이하게 될 수 있을 것이다.


그림 3-8. 도형을 추상화한 Shape 클래스
Enlarge
그림 3-8. 도형을 추상화한 Shape 클래스

객체지향에서는 위의 예와 같이 ‘일반화’를 통해서 클래스들 사이에 어떤 연관성을 가질 수 있도록 하는 메커니즘을 제공하고 있는데, 그 것이 바로 상속이다. 본 절의 앞에서 이야기 했듯이 이 점이 코드 수준의 재사용성과 다른 상속의 또 다른 면이다. 위의 예제의 원과 사각형이 가지는 공통적인 메시지를 일반화 시켜서 그림 3-8과 같이 ‘도형’을 추상화 하는 Shape 클래스를 정의할 수 있다.


그리고는, 부모로부터 자식이 유전적인 특징을 상속 받듯이, Circle과 Rectangle 클래스로 하여금 Shape 클래스가 가지는 멤버 함수를 상속 받게 함으로써, 앞에서 문제가 되었던 Circle과 Rectangle의 의미론적 관계를 성립하는 것이다. 그림 3-9은 이러한 상속의 관계를 UML 다이어그램으로 표현한 것이다.


그림 3-9. 클래스의 일반화
Enlarge
그림 3-9. 클래스의 일반화

앞에서의 일반화 과정을 거꾸로 생각해보자. 다시 말해서, 도형을 추상화 하는 Shape 클래스를 먼저 생각하여 일반적으로 도형이 처리할 수 있는 Draw, Rotate, Move와 같은 공통 메시지를 생각해 내고, 그 후에 Shape를 구체화 시켜서 원과 사각형 같은 도형을 추상화 한 Circle, Rectangle 같은 클래스를 Shape 클래스로부터 상속을 통하여 정의하는 것이다. 쉽게 생각할 수 있듯이 이 과정은 일반화의 역과정으로서 문제를 추상화 하는 과정이 일반화된 개념에서 구체적인 개념으로 옮겨가는 방식을 따르고 있다. 이러한 문제의 추상화 방식을 ‘구체화’라고 하며, 상속 메커니즘을 가지고 할 수 있는 또 다른 문제의 추상화 과정으로 생각해 볼 수 있다.

상속을 재사용성(Reusability)의 관점에서 이야기 할 때, 알아보았던 상속의 정의를 다시 생각해 보자. 상속은 이미 존재하는 클래스의 코드를 점진적으로 수정해 나가는 방식으로 새로운 클래스를 정의할 수 있는 메커니즘이라고 했었다. 이 정의와 바로 전에 생각해 보았던 구체화를 가능하게 해 주는 기능으로서의 상속은 동일한 내용이라고 생각하는 독자들이 있을 것이다. 역사적으로 많은 연구자들이 상속과 구체화는 동일한 것을 다른 단어를 사용하여 이야기 하고 있는 것이라고 주장해 온 것이 사실이다. 이러한 상속의 전통적인 관점에서는 상속의 필수적인 사용 이유는 문제의 구체화를 위한 것이고, 코드 수준의 재상용성은 그 부산물로 얻어진 것이라고 보기도 한다. 그러나, 이렇게 이야기 하기에는 객체지향 시스템에서 제공하고 있는 상속 메커니즘이 구체화를 확실하게 보장 할 수 없는 문제점이 지적되기도 하였다. 예를 들어서, 기본 클래스로부터 상속을 하여 새롭게 정의된 서브클래스는 메소드 재정의를 통하여 기본 클래스가 가지고 있는 재정이 된 메소드와는 의미상 서로 연관되기 어렵게 만드는 경우가 발생할 수 있다. 따라서, 이 경우는 상속이 코드 수준의 재상용성이라는 측면에서는 활용이 되고 있지만, 기본 클래스와 서브 클래스가 일반화와 구체화라는 추상적인 개념으로 묶이기에는 문제가 되는 메소드를 각각 클래스가 가지게 되는 결과를 가져오게 한 것이다. 이 외에도 상속이 구체화와 동일한 의미로 다루기에 문제가 되는 점이 여러 있지만, 이러한 논의는 이 책의 범위를 넘는 것이니 그 세세한 이야기는 여기서 접겠다.



일반화와 추상 클래스(Abstract Class)

소프트웨어 시스템을 디자인 하다 보면 추상적 의미만 가지는 클래스가 필요한 경우가 있다. 이러한 클래스는 다른 클래스와의 상속 구조에서 상위 클래스의 역할로는 충분한 의미가 있지만 그 자체의 인스턴스화는 의미가 없는 경우가 있다. 추상 클래스는 그렇지 않는 클래스와 비교하여 다음과 같은 차이점을 가지고 있다.


  • 인스턴스화가 되지 못한다.
  • 추상적인 의미에서만 그 존재 의미를 가지는 클래스이기 때문에 멤버 메소드 중에 구현부가 없는 추상 메소드를 가질 수 있다. 이런 이유로 불완전한 클래스라고도 불린다.
  • 그 자체로서는 의미를 가지지 못하기 때문에 다른 클래스와 상속 관계를 가지지 못하는 sealed 클래스가 될 수 없다.


참고 글 – sealed class

C# 언어에서는 sealed 예약어를 통하여 클래스를 다른 클래스가 상속하지 못하게 하는 메커니즘을 제공하고 있다. 예를 들어, 다음과 같이 정의된 FinalClass는

sealed class FinalClass { . . . }

다른 클래스 AnotherClass에서 다음과 같이 상속하는 것에 제한을 가한다.

class AnotherClass : FinalClass { . . . } // 오류..


일반화와 구체화에서 다루었던 예를 보면서 추상 클래스의 필요성에 대해서 생각해 보자. Shape 클래스는 단지 Circle과 Rectangle 클래스의 관계를 성립시키기 위해서 도입된 추상화 클래스로 일반화라는 관점에서 의미를 지니고 있기 때문에, 구체적인 코드를 포함하기에는 부적절하다. 사실 Shape가 Circle인지 Rectangle인지가 정해지기 전까지는 어떻게 구현해야 하는지 알 수가 없다. 객체화의 관점에서는 Circle과 Rectangle 클래스는 프로그램 실행 시간 중에 객체로 객체화 시키는 것이 필요하지만 Shape 클래스는 단지 Circle과 Rectangle 클래스를 하나의 공통 타입으로 묶는 역할만을 하는 것으로 프로그램 실행 시간 중에 객체로 객체화 할 필요성이 없게 된다. 따라서, Shape 클래스가 가지는 멤버 메소드는 공통적인 추상화 의미만을 지닐 수 있도록 메소드에 대한 선언부(signature)만 포함하고, 그 실재적인 구현 코드는 Shape 클래스를 상속하는 Circle과 Rectangle 클래스에서 이루어지게 할 필요성이 있다. 추상 클래스를 사용하면 이러한 일반화를 통한 추상화 과정의 구현을 효과적으로 할 수 있다. 그림 3-9-1은 그림 3-9에 표현되어 있는 클래스를 이용한 일반화를 추상 클래스를 이용한 일반화로 표현해 본 것이다( UML 다이어그램에서 이탤릭채로 되어 있는 것이 추상화된 부분을 표현하고 있다).


그림 3-9-1. 추상 클래스를 이용한 일반화
Enlarge
그림 3-9-1. 추상 클래스를 이용한 일반화


객체지향 프로그래밍 언어에서 추상 클래스가 어떤 식으로 구현되어 있는지 살펴보자.


C#

추상 클래스를 지원하기 위하여 C#에서도 다른 범용적인 객체지향 언어에서처럼 ‘abstract’ 라는 예약어를 통하여 추상 클래스 개념의 클래스 메커니즘을 제공하고 있다. 그림 3-9-1에 UML로 표현되어 있는 예를 C#의 추상 클래스를 통해서 구현한다면 다음과 같은 코드가 될 수 있을 것이다.

abstract class Shape {
   // 구현부가 전혀 없는 abstract 메소드들
   public abstract void Draw();
   public abstract void Rotate();
   public abstract void Move();
}

class Circle : Shape {
   private float xPoint;
   private float yPoint;
   private float radius;

   // abstract 클래스인 Shape 클래스에 선언되어 있는
   // 모든 abstract 메소드에 대한 구현 코드
   public override void Draw() {
       // Draw 메소드에 대한 구현 코드
       . . .
   }
   public override void Rotate() {
      // Rotate 메소드에 대한 구현 코드
       . . .
   }
   public override void Move() {
      // Move 메소드에 대한 구현 코드
       . . .
   }
}

class Rectangle : Shape {
   private float xPoint;
   private float yPoint;
   private float ratio;

   public override void Draw() { . . .}
   public override void Rotate() { . . . }
   public override void Move() { . . . }
}

C#에서 추상 클래스를 상속하는 클래스를 구현할 경우에는 주의할 점이 있다. 추상 클래스를 상속하는 클래스를 비추상 클래스로 하기 위해서는 추상 클래스에 선언되어 있는 모든 추상 메소드(abstract method)를 override하여 구현해 주어야 한다는 것이다. 그렇지 않을 경우에는 추상 클래스를 상속하는 클래스를 추상 클래스로 만들어야 한다. 위의 코드에서도 이와 같은 점이 잘 드러나 있다. 추상 클래스인 Shape를 상속하는 Circle과 Rectangle 클래스를 모두 비추상 클래스로 정의하기 위하여 Shape에 선언되어 있는 세 개의 추상 메소드를 모두 override하여 구현하고 있는 것을 볼 수 있다. 메소드 재정의는 부모 클래스에 정의되어 있는 virtual 메소드 뿐만 아니라 추상 메소드를 구현하는 곳에서도 사용된다는 것에 주의하자.

Java

Java 언어도 'abstract’ 라는 예약어를 통하여 추상 클래스 개념의 클래스 메커니즘을 제공하고 있다.


상속과 연관 지어 생각해 볼 주제들..

지금까지는 상속의 일반적인 개념에 초점을 맞추어서 생각해 보았다. 상속 개념을 떠받치고 있는 그 기본적인 생각만으로는 너무 간단하다 싶을 정도의 개념이고 그 개념을 구현한 메커니즘으로 생각할 수 있다. 그리고, 기존 코드의 재사용 능력과 문제 영역의 추상화 능력에 초점을 맞추어서 생각해 보면 상속이 절차와 같은 다른 패러다임에 비하여 객체지향 패러다임을 어느 정도 우위에 두게 하는 것처럼 생각할 수도 있다. 지금까지의 이야기대로 어느 정도는 사실이다. 하지만 상속 메커니즘을 소프트웨어 설계와 구현에 적용하는 것은 그리 간단하지가 않다.

상속의 기본적인 의미를 알아보기 위하여 지금까지 상속의 예로 들었던 것들을 관찰해 보면 모두 코드 수준에서 슈퍼 클래스의 멤버 필드와 메소드를 서브 클래스에서 재사용하고 있는 형태를 가지고 있다는 것들이다. 이런 종류의 상속을 구현 상속(Implementation Inheritance)라고 한다. 구현 상속 이외에도 구현이 없는 인터페이스 사이의 상속 관계인 인터페이스 상속(Interface Inheritance)도 존재한다. 인터페이스 상속은 구현 상속과는 또 다른 의미를 제공하여, 소프트웨어 설계에 적용할 유용한 꺼리를 제공하는 메커니즘이다.

이 절에서는 이와 같이 상속의 기본적인 생각을 기본으로 하여 상속 적용의 어려움과 구현 상속과는 다른 의미를 가진 인터페이스 상속에 대하여 생각해 보고자 한다.



상속이 주는 모호성

잠시 C#으로 구현된 다음 소스 코드를 보자.

class Subclass : Superclass {
   public override int doSomething(int _value) {
       return _value;
   }
}

Subclass는 Superclass를 상속하여 정의되어 있으며, Superclass에 정의되어 있는 doSomethind()이라는 메소드를 재정의 하고 있음을 알 수 있다. Subclass에 대한 다음 질문에 답해 보자.


  • Subclass의 doSomething()은 Superclass의 abstract 메소드를 구현하고 있는 것인가 아니면 Superclass에 정의되어 있는 virtual 메소드를 재정의 하고 있는 것인가?
  • Subclass의 doSomething() 메소드가 왜 이런 식으로 정의되어 있는지 이야기 할 수 있는가?


C# 언어에서는 상속 구조에서 상위 클래스에 virtual로 정의되어 있는 메소드를 하위 클래스에서 재정의하는 경우와 상위 클래스의 abstract 메소드를 하위 클래스에서 구현하기 위해서는 두 경우 모두 해당 메소드에 override 예약어를 명시해 주어야 한다. 따라서, Subclass의 상위 클래스인 Superclass의 코드를 보지 않고는 첫 번째 질문에 대한 답을 할 수 없다. 두 번째 질문의 경우도 비슷하다. Subclass는 Superclass를 상속하고 있기 때문에 Superclass로부터 상속한 멤버 필드와 멤버 메소드를 가지고 있을 것이다. 따라서, 위의 소스 코드에서 보이는 것 이상의 클래스 멤버 필드와 메소드가 존재할 수 있으며 Superclass의 소스 코드를 보지 않은 상황에서는 doSomething과 같은 메소드가 필요한 상황을 짐작하기 쉽지 않은 것이 사실이다.

위의 간단한 C# 코드를 통해서 상속 메커니즘이 주는 코드 재사용의 혜택이 오히려 정의된 클래스의 의미를 ‘모호'하게 만드는 결과를 가져올 수도 있다는 것을 생각하게 한다. 이처럼 상속 구조에 있는 클래스를 정확히 이해하거나 새로운 클래스를 기존의 클래스로부터 상속하여 정의하기 위해서는 해당 클래스의 상속 계층 구조상에 상위에 정의되어 있는 모든 클래스를 이해해야 한다는 좀처럼 납득하기 어려운 상황을 상속 메커니즘이 만들어 내고 있는 것이다. 서로 연관성을 가지는 클래스를 상속 관계에 두어 중복되는 코드는 공유하고 문제의 추상도를 높이는 것은 분명 상속 메커니즘의 장점이지만 상속 구조의 최상위 클래스에서 최하위 클래스에 이르는 클래스의 단계가 많을수록 상속 구조에 포함되어 있는 클래스가 가지는 의미가 점점 흐려지는 결과를 가져 올 수 있다. 상속을 적용할 때 주의할 점이다.


참고 글

첫 번째 질문은 C#언어의 문제처럼 보이지만 다른 객체지향 언어도 비슷한 문제점을 가진다. 위의 소스 코드를 Java 언어로 구현하면 다음과 같은 코드가 된다.

class Subclass extends Superclass {
       public int doSomething(int _value) {
           return _value;
       }
}

위의 코드에 대하여 첫 번째 질문을 하면 답할 수 있을까? C#의 경우와 비슷한 모호한 상황에 빠지게 된다.


상속 메커니즘이 가지고 있는 코드의 모호성 문제는 관련 상속 구조에 대한 상세한 내용을 적은 문서를 코드와 함께 유지하는 것이 최선책이라 생각된다. .NET 프레임워크에서 제공하는 클래스 라이브러리에 대한 계층 구조에 대한 상세한 문서는 해당 라이브러리에 포함되어 있는 클래스에 대한 설명과 함께 클래스 계층 구조에 따른 클래스의 모호성 문제를 해결하려는 이유가 있다고 볼 수 있다.



상속과 서브타이핑(Subtyping)

상속 메커니즘을 적용할 때 특별히 신경을 써야 하는 두 가지 구별된 개념이 있다. 서브클래싱(subclassing)과 서브타이핑(subtyping)이 바로 그것이다. 이 두 개념이 혼동되어 구축된 상속 구조의 클래스는 그 의미가 불완전하여 해당 상속 구조의 클래스를 이용하는 소프트웨어 시스템이 정상적으로 동작하는데 문제를 일으킬 수 있다. 이 두 개념과 그 이유를 생각해 보겠다.

whoAmI()라는 메소드를 멤버로 갖는 클래스 A를 정의하고 A 클래스의 성질을 상속하는 클래스 B를 다음 소스와 같이 정의하여 보았다.

class A {
   public void whoAmI() {
       Console.WriteLine(“this is class A”);
   }
}

class B : A {
   . . .
}

class TestApp {
   // TestApp 클래스의 checkWhoAmI 메소드는 A 클래스를 염두해 두고
   // 정의된 것이다. 따라서, 인자로 A 클래스 타입의 객체를 받아들이도록 하고 있다.
   public void checkWhoAmI(A _object) {
       _object.whoAmI();
   }

   public static void Main() {
       A a = new A();
       B b = new B();
       TestApp app = new TestApp();
       app.checkWhoAmI(a);                     // (1)
       app.checkWhoAmI(b);                     // (2)
   }
}

위의 C# 소스 코드를 컴파일하여 실행시키면 다음과 같은 결과를 얻게 된다.

this is class A    ----- (1) 수행에 따른 결과
this is class A    ----- (2) 수행에 따른 결과

첫 번째 줄의 출력은 소스 코드의 (1)이 실행된 결과이고 두 번째 줄의 출력은 소스 코드의 (2)가 실행된 결과이다. 이 짧은 소스 코드를 실행시켜 보고 우리는 다음과 같은 두 가지 사항에 주의를 기울여 볼 수 있다.


  • 분명히 TestApp 클래스의 checkWhoAmI(A _object) 메소드의 인자로 A 클래스의 인스턴스만 받아들이도록 코드를 만들었는데도, 소스 코드의 (2)처럼 B 클래스의 인스턴스인 b를 checkWhoAmI()의 인자로 넘기는 코드를 포함시켜도 위의 소스 코드는 아무 오류 없이 컴파일 되고 실행되고 결과까지 출력되었다.
  • TestApp 클래스에 정의되어 있는 checkWhoAmI(A _object) 메소드는 인자로 A 클래스의 인스턴스를 받아들이도록 정의하였다. 이 것은 checkWhoAmI() 메소드가 A 클래스에 대하여 어떤 기대하는 연산을 하기 위한 의도를 가지고 정의되었다는 것을 의미하는 것이다. 여기서 기대하는 목적은 인자로 넘어온 A 클래스의 인스턴스에 whoAmI 메시지를 전달하고 그에 대한 결과로 인스턴스가 기반하고 있는 클래스의 정확한 이름이 출력되는 것을 보고 싶은 것이다. (2) 수행 결과는 이 같은 기대에 어긋나는 결과이다.


위와 같은 사항에 대한 이해를 하기 위해서는 상속의 의미를 다시 생각해 보아야 한다. 지금까지는 이미 존재하는 클래스에 기반 하여 새로운 클래스를 정의하는 것을 모두 상속한다고 이야기 하였다. 그러나, 상속 관계에 있는 두 클래스 사이의 관계를 자세히 살펴보면 모두 똑 같은 상속 관계에 있다고 말하기에는 어려움이 따른다. 즉, 상속 관계에 있는 두 클래스 사이의 관계가 가지는 특징에 따라서 상속을 구분할 필요가 있다는 것이다.

‘구현’ 관점에서만 상속을 보면, 상속은 기존의 클래스(슈퍼클래스)가 가지는 모든 멤버 필드와 메소드를 이를 상속하는 새로운 클래스(서브클래스)에 모두 전달하여 버린다. 이러한 상속에서는 서브클래스에서 슈퍼클래스로부터 상속 받은 일부 메소드를 재정의한다 하더라도 그 메소드 이름에는 변경이 가해지지 않기 때문에 서브클래스에서 슈퍼클래스의 모든 멤버가 존재한다고 기대할 수 있는 특징을 가지고 있다. 이런 관점에서의 상속을 ‘서브클래싱(subclassing)’이라 하여 다른 종류의 상속과 구분한다. 첫 번째 사항은 바로 C# 언어가 서브클래싱(subclassing)을 언어적 수준에서 지원하고 있기 때문에 일어난 현상이다. 메소드의 인자로 A 클래스의 인스턴스를 받는 것으로 정의되어 있는 TestApp의 checkWhoAmI(A _object) 메소드는 인자로 A 클래스의 인스턴스가 아닌 B 클래스의 인스턴스를 넘겨주는 소스 코드의 app.checkWhoAmI(b)가 아무 문제 없이 컴파일 되고 프로그램 실행 중에 AppTest 객체가 B 클래스의 인스턴스인 b 객체에 b.whoAmI() 메시지를 전달하는 과정에서도 아무런 문제가 없었던 것은 B 클래스가 A 클래스의 모든 멤버를 상속하고 있는 서브클래싱의 특징 때문이다.

구현 관점, 즉 서브클래싱의 관점에서만 보면 앞의 소스 코드는 아무런 문제를 가지고 있지 않다. 기계적으로 그 구현 내용을 살펴보는 C#의 컴파일러와 프로그램 실행 중에 동적으로 해당 객체를 찾아서 whoAmI 메시지를 전달하는 .NET의 프로그램 실행 환경의 입장에서는 위의 코드가 정상적이라고 판단되는 것이다. 하지만, 두 번째 항목에서처럼 클래스 A의 인스턴스를 염두해 두고 정의한 checkWhoAmI(A _object) 메소드가 클래스 A와 서브클래싱 관계에 있는 클래스 B의 인스턴스에 실행될 때, whoAmI 메시지를 받은 객체가 자신이 기반하고 있는 클래스의 정확한 이름을 출력하지는 못하고 있음을 확인해 볼 수 있다. 즉, 슈퍼클래스에 기대하는 ‘행위’를 서브클래스에서도 기대하기에는 서브클래싱만으로 충분하지 않다는 것을 생각해 볼 수 있다. 이렇게 하기 위해서는 서브클래스가 슈퍼클래스가 가지는 행위 자체도 상속하게 하는 상속 관계가 필요하며, 이를 구현관점에서의 서브클래싱과 구별하여 ‘서브타이핑(subtyping)’이라 한다. 그 이름에 나타나 있듯이 서브타이핑은 타입(type) 개념을 상속에 적용시켜서 구현뿐만 아니라 행위도 서브클래스가 상속 받도록 하는 개념의 상속 관계이다. 위의 소스 코드에서 클래스 B는 클래스 A와 서브클래싱 관계는 있지만 서브타이핑 관계는 가지지 않는다. 다르게 표현하면 클래스 B는 클래스 A의 서브클래스이기는 하지만 서프타입은 되지 못한다.

서브클래싱과는 달리 서브타이핑은 전적으로 개발자의 몫이다. 행위에 해당하는 부분은 C#의 컴파일러와 .NET의 프로그램 실행 환경이 기계적으로 알 수 없는 영역의 부분이기 때문이다. 위의 소스 코드의 클래스 B가 클래스 A의 서브타입이 되게 하려면 클래스 A로부터 상속 받는 whoAmI() 메소드 부분을 의미에 맞게 재정의 해 주어야 한다.

class A {
   public virtual void whoAmI() {
       Console.WriteLine(“this is class A”);
   }
}

class B : A {
   // 클래스 A의 서브타입이 되기 위하여 재정의된 whoAmI 메소드
   public override void whoAmI() {
      Console.WriteLine(“this is class B”);
   }
}


참고 글

Liskov Substitution Principle

프로그래밍 언어 분야에서 서브타입과 관련하여 많은 연구가 이루어져 왔다. 그 결과로 Barbara Liskov라는 사람이 두 타입이 서브타입 관계에 있는지를 확인해 볼 수 있는 ‘Liskov Substitution Principle’라는 원리를 다음과 같이 정리해 놓았다.


If for each object o1 of type S there is another object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.


T 타입에 기반 하여 정의된 프로그램 P가 있다. 프로그램 P를 또 다른 타입 S의 모든 객체에 대하여 적용할 경우에도 T 타입의 객체에 적용할 경우와 비교하여 프로그램 P의 행위에 변화가 없을 경우에 S 타입은 T 타입의 서브타입이다.


이 원리를 클래스간의 상속 관계에 적용하면 두 클래스가 단순히 구현 수준의 서브클래싱 관계에 있는지 아니면 행위까지도 상속되어 있는 서브타이핑 관계에 있는지 확인해 볼 수 있다.



지금까지 생각해본 것처럼 클래스 간의 상속 관계를 정의할 때는 단순 구현 수준의 서브클래싱 관계로 정의하는 것으로 충분한지 아니면 행위에 대한 상속도 고려해야 하는 서브타이핑 관계로 정의해야 할지를 명확히 구분해서 적용해야 한다. 단순히 서브클래싱 관계만으로 적용하여 프로그램 내에서 서브타이핑 관계로 사용하게 되면 프로그램을 통해서 기대하는 결과를 얻어내지 못할 것이다. 경우에 따라서는 컴파일과 프로그램 실행 중의 문제가 눈에 띠지 않으므로 프로그램 상의 문제가 되는 부분을 찾기도 힘들어지게 된다.



상속과 인터페이스(Interface)

인터페이스 간의 상속 관계는 클래스 간의 상속 관계와 비교하여 공통점과 차이점을 가지고 있다. 공통점으로는 상속 관계에 있는 하위 인터페이스가 상위 인터페이스에 정의되어 있는 멤버를 코드 수준에서 모두 상속 받는 다는 것이다. 하지만, 소프트웨어 시스템에서 인터페이스 간의 상속 관계는 클래스 사이의 상속 관계와 다른 의미를 가진다. 인터페이스 간의 상속이 가지는 의미를 이해하기 위하여 추상 데이터 타입(Abtract Data Types)의 5.1.3 인터페이스(Interface)에서 생각해 본 의존성의 측면이 아닌 인터페이스가 가지는 다른 의미를 먼저 생각해 볼 필요가 있다.


공통 타입 기능의 인터페이스(Interface)

C# 또는 Java언어의 인터페이스(Interface)는 코드 수준에서 클래스의 인터페이스부와 구현부를 분리해 줌으로써 클래스에서 제공하는 서비스에 대한 외부 클래스와의 의존성을 줄일 수 있다. 그런데, 이런 관점에서는 .NET 클래스 라이브러리에서 제공하고 있는 인터페이스에 붙어 있는 ICloneable, IComparable, IEnumerable 등과 같은 이름을 이해하기 어렵다(Java 클래스 라이브러이에는 Cloneable, Comparable로 정의되어 있다). ‘복제할 수 있는(cloneable)’, ‘비교할 수 있는(comparable)’, ‘열거할 수 있는(enumerable)’과 같은 의미의 이름을 통해서 볼 때, 이들 인터페이스를 구현하고 있는 클래스의 객체는 이 인터페이스를 구현하고 있지 않은 객체와 비교하여 복제가 가능한 객체고, 서로 비교가 가능한 객체이고, 열거가 가능한 객체다라는 의미로 미루어 짐작된다. 사실 이들 인터페이스는 이런 의미로 사용되며 이는 인터페이스가 클래스를 ‘공통 타입으로 묶는 목적’으로 사용될 수 있다는 것을 보여 준다. 그럼 어떻게 인터페이스가 클래스를 하나의 공통 타입으로 묶는 기능을 할 수 있는 것일까?

그림 3-10은 인터페이스를 통해서 한 객체가 다른 객체를 바라보는 것이 어떤 의미를 가지는지 잘 표현해 주고 있다. 다른 곳은 막혀 있고 중간에 직사각형의 창이 있는 것이 인터페이스고 이러한 인터페이스가 만드는 그림자 아래에 위치한 타원 모양의 것이 이 인터페이스를 구현한 클래스의 객체이다. 인터페이스의 창은 인터페이스에 구현부 없이 선언만 되어 있는 메소드를 의미하는 것이고, 인터페이스 그림자 밑에 놓여 있는 객체가 해당 인터페이스의 창을 통해서 들어오는 빛 보다 더 큰 것은 인터페이스에 선언되어 있는 메소드를 모두 구현하고 있다는 것을 의미한다. 이와 같은 상태에서 인터페이스를 구현한 객체의 본 모양은 타원이지만 인터페이스를 통해서 바라보이는 객체의 모습은 직사각형처럼 보인다. 그 이유는 인터페이스는 직사각형의 창을 제공하고 있고, 그 아래 놓인 객체의 크기는 인터페이스의 창 크기 보다 더 커서 자신의 본 모습을 드러낼 수 없기 때문이다. 인터페이스는 그대로 놔두고 인터페이스를 구현한 객체를 다른 객체로 바꾸면 어떻게 보일까? 객체의 모양이 바뀌어도 인터페이스의 모양이 똑 같기 때문에 똑 같은 인터페이스를 통해서 바라보이는 객체의 모습은 항상 동일하게 보일 것이다.

그림 3-10. 인터페이스를 통한 객체의 모습
Enlarge
그림 3-10. 인터페이스를 통한 객체의 모습

이렇듯 동일한 인터페이스를 구현하고 있는 서로 다른 객체들은 그 자신의 모습이 제 각각임에도 불구하고 모두 같은 모양(타입)의 객체로 인식될 수 있다. 다시 말해서, 동일한 인터페이스를 구현한 서로 다른 클래스는 해당 인터페이스를 통해서 하나의 공통 타입으로 묶여 처리될 수 있다. 아래 소스 코드는 이런 인터페이스의 성질을 적용한 예이다.

interface ITalkable {
   public void saySomething();
}

class A : ITalkable {
   // ITalkable에 정의 되어 있는 saySomething()의 구현 코드
   public void saySomething() {
       Console.WriteLine(“My name is A”);
   }
   // 클래스 A에 정의되어 있는 다른 멤버
   . . .
}

class B : ITalkable {
   // ITalkable에 정의 되어 있는 saySomething()의 구현 코드
   public void saySomething() {
       Console.WriteLine(“My name is B. I can talk.. ”);
   }
   // 클래스 B에 정의되어 있는 다른 멤버
   . . .
}

public class TestApp {
   public checkTalkable(ITalkable _object) {
       _object.saySomething();
   }

   public static void Main() {
       TestApp app = new TestApp();
       app.checkTalkable(new A());
       app.checkTalkable(new B());
   }
}


ITalkable(말할 수 있는) 인터페이스를 구현하는 모든 클래스는 ITalkable 인터페이스에 정의되어 있는 saySomething()이라는 메소드를 구현하고 있기 때문에, ITalkable 인터페이스를 통하여 해당 클래스의 객체에 접근하는 외부 클라이언트는 그 구체적인 객체에 상관없이 동일하게 saySomething(말해봐!) 메시지를 전달하고, 메시지를 받은 객체는 그에 대한 처리(무언가를 말한다)를 한다. 따라서, ITalkable을 구현하는 모든 클래스는 saySomething 메시지를 처리할 수 있는(말한 수 있는 능력을 가진) 클래스들의 집합으로 묶여 동일하게 처리될 수 있는 추상성을 부여해 준다.

다시 처음의 의문으로 돌아가서 생각해 보면, .NET 클래스 라이브러리에서 정의하여 제공하고 있는 ICloneable, IComparable, IEnumerable 등과 같은 인터페이스는 모두 클래스들을 공통 타입으로 묶는 추상성을 소프트웨어 시스템에 부여하기 위하여 제공되고 있는 것들로 이해하면 될 것이다. 인터페이스와 클래스와의 이런 관계는 앞에서 생각해본 클래스 사이의 서브타이핑 관계는 아니지만 이와 비슷한 효과를 부여해 주어 다음에 생각해 볼 다형성(Polymorphism)과 연관되어 소프트웨어의 추상도를 높여주는 아주 유용한 역할을 한다. 위의 ITalkable 인터페이스의 예에서 보여주듯이 ITalkable 인터페이스를 염두해 두고 구현된 TestApp 클래스의 checkTalkable() 메소드가 서브타이핑 관계가 반영된 코드에서처럼 ITalkable 인터페이스를 구현한 모든 클래스의 객체에 그대로 적용될 수 있다. .NET 클래스 라이브러리의 System 네임 스페이스에 정의되어 있는 IComparable 인터페이스가 공통 타입 기능으로 사용되는 구체적인 예를 살펴보는 것으로 인터페이스가 가지는 또 다른 의미를 나름대로 정리해 보기 바란다.

using System;
class Point : IComparable {
   public Int32 x, y;

   public Point(Int32 x, Int32 y) {
      this.x = x;
      this.y = y;
   }

   // CompareTo is defined in the IComparable interface
   public Int32 CompareTo(Object other) {
      Point p = (Point) other;
      Double thisDistanceFromOrigin = Math.Sqrt(x * x + y * y);
      Double otherDistanceFromOrigin = Math.Sqrt(p.x * p.x + p.y * p.y);
      return(Math.Sign(thisDistanceFromOrigin -  otherDistanceFromOrigin));
   }

   public override String ToString() {
      return(String.Format("({0}, {1})", x, y));
   }
}

public class TestApp {
   public static void Main() {
       
      Point[] points = new Point[5];
      points[0] = new Point(2, 2);
      points[1] = new Point(3, 2);
      points[2] = new Point(2, 3);
      points[3] = new Point(7, 8);
      points[4] = new Point(0, 1);

      Array.Sort(points);

      // Display all the elements in the array
      for (Int32 i = 0; i < points.Length; i++) 
           Console.WriteLine("Point {0}: {1}", i, points[i]);

      Console.Write("Press Enter to close window...");
      Console.Read();
   }
}


지금까지의 생각을 정리하여 객체, 클래스, 인터페이스 간의 관계를 그림 3-11와 같이 표현할 수 있을 것이다. 클래스는 공통 성질을 가지는 객체를 하나의 공통 타입으로 묶는 메커니즘이고, 인터페이스는 클래스를 다시 공통 타입으로 묶는 메커니즘으로 말이다.


그림 3-11. 객체, 클래스, 인터페이스 간의 관계
Enlarge
그림 3-11. 객체, 클래스, 인터페이스 간의 관계



다중 인터페이스를 구현하는 클래스

앞에서 인터페이스 메커니즘이 제공하는 또 다른 특징인 공통 타입 기능에 대해서 생각해 보았다. 이것은 하나의 인터페이스에 여러 개의 클래스가 연관되어 있는 경우를 기반으로 하고 있는 유용한 기능이다. 이제 생각을 좀 달리 해보자. 하나의 클래스에 하나 이상의 인터페이스가 연관되어 있는 경우는 어떻게 생각할 수 있을까? 이런 상황에서는 인터페이스 메커니즘이 어떤 의미를 가질까? 한 번 생각해 보자.

이번에는 클래스를 중심으로 그림 3-10을 바라보자. 클래스가 삼각형 모양의 창을 가지는 인터페이스도 구현을 한다면, 객체는 어떻게 보일까? 인터페이스에 따라서 직사각형으로도 보이고 삼각형으로도 보일 것이다. 이와 같이 하나 이상의 인터페이스를 구현하는 클래스는 ‘하나 이상의 타입을 가지는 클래스’로 간주될 수가 있다. 예를 들어, 앞에서 IComparable 인터페이스를 구현한 Point 클래스가 아래 보이는 소스 코드에서와 같이 동시에 System. Runtime.Serialization 네임스페이스에 구현되어 있는 ISerializable 인터페이스도 구현하도록 해 보자.

// 두 개의 IComparable, ISerializable 인터페이스를 구현하는 Point 클래스
class Point : IComparable, ISerializable {
   . . .
   // ISerializable 인터페이스의 멤버 GetObjectData의 구현부
   public void GetObjectData(SerializationInfo info, 
                            StreamingContext context) {
       info.AddValue(“x”, x);
       info,AddValue(“y”, y);
   }

   // deserialization을 위한 생성자
   public Point(SerializationInfo info, StreamContext context) {
       x = (Int32)info.GetValue(“x”, typeof(Int32));
       y = (Int32)info.GetValue(“y”, typeof(Int32));
   }
   . . .
}


위와 같이 구현된 Point 클래스는 이제 IComparable 인터페이스를 염두해 두고 구현된 다른 클래스의 메소드뿐만 아니라 ISerializable 인터페이스를 염두해 두고 구현된 다른 클래스의 메소드에서도 기대하는 행위를 하는 클래스가 되었다. 다형성의 관점에서 보면 Point 클래스의 객체는 다중 인터페이스 구현으로 인해서 ‘다형적인 객체(polymorphic object)’가 된 것이다.



인터페이스 간의 상속

C#, Java와 같은 객체지향 언어에서는 인터페이스 간의 상속 관계를 허용하고 있다. 코드 수준에서 볼 때, 인터페이스 간의 상속은 클래스의 경우처럼 상속 관계에 있는 상위 인터페이스의 멤버를 하위 인터페이스가 모두 상속하기 때문에 클래스의 상속과 다르지 않다. 그럼, 의미적 관점에서도 인터페이스 간의 상속은 클래스의 상속과 같은 의미를 가지고 있을까? 한 번 생각해 보자.

세 개의 ICloneable, IShallowCopy, IDeepCopy 인터페이스가 상속 관계에 놓여 있는 다음의 예제 코드를 보자(그림 3-11-1).

interface ICloneable {
    // IClonable 인터페이스는 멤버를 가지지 않는다.
}
interface IShallowCopy: ICloneable {
    void ShallowCopy(Object _object);
}
interface IDeepCopy: ICloneable {
    void DeepCopy(Object _object);
}


그림 3-11-1. 인터페이스 간의 상속 1
Enlarge
그림 3-11-1. 인터페이스 간의 상속 1


IShallowCopy와 IDeepCopy 인터페이스가 ICloneable 인터페이스를 상속하고 있는 구조이다. IShallowCopy와 IDeepCopy는 모두 객체의 복제와 관련이 있는 인터페이스로 객체의 복제 수준에 따라서 Shallow copy만 허용 되는 객체의 클래스와 Deep copy만 허용되는 객체의 클래스의 타입을 각각 구분하기 위한 것이다. 그러나, 경우에 따라서는 객체 복제의 수준에 관계없이 객체 복제가 가능한 모든 클래스를 하나의 ‘타입’으로 묶을 필요성이 생긴다. 이런 필요성에서 IShallowCopy와 IDeepCopy를 일반화하는 ICloneable이라는 상위 인터페이스를 정의하고 인터페이스 간의 상속 관계를 정의한 것이다.


참고 글 – 객체 복제(Object Copy)

객체 복제(Object Copy)는 그 수준에 따라서 Shallow copy와 Deep copy로 구분된다. 객체 복제와 관련된 구체적인 내용은 Prototype 패턴을 참고하기 바란다.


참고 글 – Tagging Interface

ICloneable는 멤버를 전혀 가지고 있지 않아서 필요 없이 보이지만, 앞에서 생각해본 인터페이스의 공통 타입 기능의 입장에서 보면 그 의미를 가지고 있다. ICloneable의 목적은 IShallowCopy와 IDeepCopy 두 인터페이스를 하나의 타입으로 묶기 위한 기능으로 충분하기 때문에 멤버를 가지지 않게 정의된 것이다. ICloneable과 같이 타입을 구분하기 위한 목적으로만 정의되어 멤버를 가지지 않는 인터페이스를 ‘Tagging interface’라고 하여 구분 한다.



인터페이스의 상속 구조가 가지는 의미를 더 느껴 보기 위해서 앞의 인터페이스 상속 구조를 다음과 같이 ICloneable이 IShallowCopy와 IDeepCopy 인터페이스를 상속하는 구조로 수정해 보자(그림 3-11-2).

interface IShallowCopy {
   void ShallowCopy(Object _object);
}
interface IDeepCopy {
   void DeepCopy(Object _object);
}
interface ICloneable : IShallowCopy, IDeepCopy {
   // IClonable 인터페이스는 멤버를 가지지 않는다.
}


그림 3-11-2. 인터페이스 간의 상속 2
Enlarge
그림 3-11-2. 인터페이스 간의 상속 2


위와 같이 수정된 상속 구조에서는 ICloneable이 다른 의미를 가지게 된다. 구조를 변경하기 전에는 단순히 객체 복제가 가능한 클래스를 하나의 타입으로 묶는 tagging interface의 역할만 하고 있었지만, 이제는 IShallowCopy와 IDeepCopy에 정의되어 있는 모든 멤버를 모두 상속하여 Shallow copy와 Deep copy가 모두 가능한 클래스를 하나의 ‘타입’으로 묶는 인터페이스를 의미하게 되었다. 따라서, ICloneable를 구현하는 클래스는 IShallowCopy의 ShallowCopy() 메소드와 IDeepCopy의 DeepCopy() 메소드를 모두 구현해 주어야 한다.

여기서 클래스의 상속 관계와의 차이점을 생각해 보자. 클래스의 상속 관계에서는 프로그래머가 서브타입핑 관계를 부여해 주기 전에는 프로그램 언어 구조적으로는 서브클래싱에 머물러 있게 되어 클래스의 상속 관계를 통해서 공통 타입의 기능을 부여하기가 쉽지 않다. 그 이유는 클래스에는 클래스 인터페이스의 구현부가 정의되어 있기 때문이다. 이에 반하여 인터페이스의 상속 관계는 프로그램 언어 구조적으로 클래스들을 필요에 따라서 공통 타입으로 묶는 것이 가능하다. 구현부가 없이 인터페이스에 해당하는 메소드의 signature만 있기 때문이다. 그러나, 인터페이스의 상속 관계는 구현부가 없기 때문에 클래스의 상속 구조에서의 서브클래싱과 같은 코드 재사용등의 의미를 찾기 어렵다는 점이 있다.


참고 글 – 다중 상속과 단일 상속

C#, Java 언어에서 인터페이스는 하나 이상의 인터페이스로부터 상속하는 것을 허용 하고 있는 반면에, 클래스는 하나의 클래스에서만 상속을 허용하도록 제한이 가해져 있다. 전자의 경우를 ‘다중 상속(Multiple inheritance)’이라 하고 후자의 경우를 ‘단일 상속(Single inheritance)’이라 하여 구분한다.



지금까지 생각해 본 것처럼 그 언어적 구조가 동일하다고 해서 클래스 간의 상속, 클리스와 인터페이스 간의 상속, 인터페이스 간의 상속이 동일한 의미를 가지지 않는다. 따라서, 상속을 적용할 때 앞에서 생각해 본 상속의 모호성, 서브클래싱, 서브타이핑과 함께 클래스 간의 상속, 클리스와 인터페이스 간의 상속, 인터페이스 간의 상속도 고려하여 적용해야 할 것이다. 상속은 그 기본 의미에 비해서 소프트웨어 시스템에 설계하고 구현하기 쉽지 않은 메커니즘인 것 같다.


인터페이스(Interface)와 추상 클래스(Abstract class)

추상 클래스(Abstract class)와 interface는 모두 다른 클래스와의 상속 관계를 통하여 그 의미를 가지며, 소프트웨어 디자인과 구현시에 어느 것을 사용할 것인가를 판단하는 문제는 그리 간단한 것이 아니다. 여기서는 3, 4장을 거치면서 생각해 왔던 상황들을 예로 들면서 interface와 abstract class 사이에 어떤 것을 적용하면 좋을지 생각해 보도록 하려 한다.

3.4.3의 ArrayQueue와 LinkedListQueue 클래스의 예를 다시 생각해 보자. 3.4.3에서는 이 두 클래스의 공통 인터페이스를 위하여 IQueue라는 interface를 정의하고 사용하였다. Abstract 클래스를 이용하면 IQueue의 역할을 대신하는 추상 클래스를 다음과 같이 정의하고 이로부터 ArrayQueue, LinkedListQueue 클래스를 상속하게 할 수 있다.

// IQueue를 대신하는 추상 클래스
abstract class AbstractQueue {
   public abstract void Add(int _value);
   public abstract void Remove();
   public abstract int Peek();
} 

// IQueue 대신에 AbstractQueue로부터 상속
class ArrayQueue : AbstractQueue {. . . }
class LinkedListQueue : AbstractQueue {. . .}

IQueue 인터페이스 대신에 AbstractQueue 추상 클래스를 위와 같이 정의하여 적용해도 ArrayQueue와 LinkedListQueue의 공통 인터페이스를 정의하여 특정 큐에 대한 클라이언트의 의존성을 줄인다는 관점에서는 같은 목적을 이루어 낼 수 있다. 그러나, 인터페이스와 구현부를 코드 수준에서 분리한다는 측면에서는 추상 클래스를 사용하면 제약이 따르게 된다. 그 이유는 C# 언어는 클래스에 대하여 단일 상속만을 허용하고 있기 때문이다. 즉, 어떤 클래스의 인터페이스를 논리적으로 하나 이상으로 분리하여 디자인할 필요가 있을 경우에는 추상 클래스를 사용할 수 없고, 인터페이스만을 적용할 수 있다.

상속의 일반화 관점에서 ArrayQueue와 LinkedListQueue를 다시 생각해 보자. 이 두 클래스는 내부적으로 데이터를 관리하는 데이터 구조체에 차이를 가질 뿐이지, 큐라는 공통의 성질을 가지고 있다. 데이터를 큐에 선입선출의 규칙에 맞게 넣고, 빼는 연산과 현재의 노드 수를 제공하는 연산은 큐라는 데이터 타입에만 의미를 가지는 것이다. 이런 관점에서는 IQueue라는 인터페이스를 정의하여 외부에 공통 연산을 제공하기 보다는 AbstractQueue를 이용하는 디자인이 더 효율적이다. 그 이유는 인터페이스와 추상 클래스의 본질적인 차이에 있다. 3.4.3에서 생각해 보았듯이, 인터페이스는 서로 관계가 없는 클래스도 하나의 타입으로 묶어 처리할 수 있게 하는 메커니즘으로, 그 내부에 구현부를 전혀 가질 수 없기 때문에 공통 타입으로 묶인 클래스들의 공통 성질을 일반화 시켜 주지 못한다. 단지 인터페이스만 제공할 뿐이다. 이에 반하여, 추상 클래스는 공통 성질을 가지는 클래스들로부터 일반화 과정을 통하여 추상화된 의미의 상위 클래스를 정의할 수 있도록 하고 있다. 따라서, 서로 연관이 있는 클래스의 공통 성질에 수정이 필요한 경우에는 각 클래스를 수정할 필요 없이 공통의 성질로 일반화된 추상 클래스만 수정하면 이를 상속하는 모든 클래스에 수정한 내용이 반영된다. 추상 클래스는 구현부를 포함할 수 있기 때문에 인터페이스와는 달리 이와 같은 적용이 가능한 것이다.

위와 같은 생각을 큐 추상 데이터 디자인에 반영하면 그림 3-12와 같은 보다 더 효율적인 결과를 얻을 수 있다.


그림 3-12. interface와 abstract class를 이용한 디자인
Enlarge
그림 3-12. interface와 abstract class를 이용한 디자인

큐에 관한 공통 성질은 추상 클래스인 AbstractQueue로 일반화 시키고, 큐 클래스에 대한 인터페이스는 IQueue에 정의하는 구조이다. 큐에 새로운 인터페이스가 필요하게 되면 그림 3-12에서 처럼 AbstractQueue가 새로운 인터페이스를 구현하는 구조로 확장하면 된다. 이런 구조를 취함으로써 디자인의 추상성을 한단계 더 높일 수 있다. 그림 3-12의 디자인을 소스 코드로 표현하면 다음과 같을 것이다.

// 큐 추상 데이터 타입의 인터페이스
interface IQueue {
   void Add(int _value);
   void Remove();
   int Peek();
}

// IQueue를 구현하고 큐의 공통 성질을 가지는 추상 클래스
abstract class AbstractQueue : IQueue {
   public abstract void Add(int _value);
   public abstract void Remove();
   public abstract int Peek();
}

// AbstractQueue 상속하는 구체적인 큐 추상 데이터 타입
class ArrayQueue : AbstractQueue {. . . }
class LinkedListQueue : AbstractQueue {. . .}


참고 글 – 추상 클래스와 explicit interface member

C#에서 interface를 구현하는 추상 클래스는 explicit interface member를 구현할 수 있도록 하고 있다. 이 때, 주의할 점은 explicit interface member는 그 의미상 추상적인 내용이 아니므로 abstract 멤버가 될 수 없다. 따라서, interface에서 explicit interface member를 구현하기 위해서는 이들 멤버가 다른 abstract 멤버를 호출하는 식으로 구현해 주어야 한다. 위의 AbstractQueue가 IQueue의 멤버를 explicit interface member로 구현하면 다음과 같은 소스 코드가 될 수 있다.

abstract class AbstractQueue : IQueue {

   // explicit interface member
   void IQueue.Add(int value) { Add(value); }
   void IQueue.Remove() { Remove(); }
   int IQueue.Peek() { return Peek(); }
   protected abstract void Add(int _value);
   protected abstract void Remove();
   protected abstract int Peek();

}



Personal tools