프로그래밍 공부
작성일
2024. 2. 14. 18:32
작성자
WDmil
728x90

9. 클래스 상속 활용 테크닉

상속이 없다면 클래스는 단지 특정 기능에 연관된 데이터 구조에 지나지 않는다.

 

물론 그것만으로도 절차적 프로그래밍 언어보다 크게 진보된 것 이지만 상속이 추가됨으로써 아주 새로운 지평을 열었다. 상속을 이용하면 이미 존재하는 클래스를 기반으로 새로운 클래스를 만들 수 있다.

 

이러한 방식으로 클래스는 재사용할 수 있고 확장 가능한 컴포넌트가 된다.


9.1 상속을 통한 클래스 생성

is-a관계를 배우면서 실 세계의 객체들이 is-a패턴의 계층을 가진다는 것을 살펴봤다. 프로그래밍에서 is-a 관계는 어떤 클래스를 기반으로 다른 클래스가 존재할 떄 적용된다. 이것을 구현하는 방법으로 원본 클래스의 코드를 복제하여 적절하게 일부를 바꾸거나 추가해서 새로운 클래스를 만들 수 있다.

 

하지만, 이렇게 코드를 복사해서 is-a관계의 클래스를 만드는 방법은 다음과 같은 몇 가지 이유 떄문에 문제가 있다.

 

  • 원본 클래스에 대한 버그 수정이 새로운 클래스에는 반영되지 않는다. 두 클래스는 아주 다른 코드이기 때문이다.
  • 컴파일러는 원본 클래스와 새로운 클래스의 관계를 알지 못한다. 따라서 두 클래스는 어떤 공통의 것을 파생하여 다형성을 구현한 것이 아니라 그냥 별개의 클래스다.
  • 데이터 타입 관점에서 볼 때 두 클래스는 완전 별개이기 떄문에 코드를 공유했을 뿐, 진정하게 is-a 관계를 구현한것으로는 볼 수 없다.
  • 어떤 경우에는 원본 클래스의 정의가 프리컴파일된 헤더 파일에 바이너리 상태로 존재하여 코드를 복제할 방법이 없을 수도 있다.

당연하지만 C++에서는 완전한 is-a 관계를 구현할 수 있는 기능을 언어 차원에서 제공한다.


9.1.1 클래스의 확장

클래스를 정의할 때 이미 존재하는 다른 클래스를 상속 받거나 확장 하여 작성한다는 것을 컴파일러에 알릴 수 있다.

 

이렇게 하면 새로 작성하는 클래스가 기존 클래스의 데이터 멤버와 메서드를 자동으로 포함하게 된다.

 

이때 기존 클래스부모 클래스 또는 베이스 클래스 또는 슈퍼 클래스

라고 하고

새로 만들어지는 클래스자식 클래스 또는 파생 클래스 또는 서브 클래스 라고 한다.

 

클래스를 확장해야 할 때 코드를 복사 붙여넣기 하지 않고 상속을 이용하면 원래 클래스와 다른 부분만 작성하여 클래스를 완성할 수 있다.

 

C++에서 클래스를 확장할 때는 확장할 클래스를 명시적으로 지정해야 한다. 상속 문법에 대한 예를 들기 위해 원본 클래스 Super와 확장될 클래스 Sub가 있다고 하자.

Class Super
{
	public:
    	Super();
        void someMethod();
    protected:
    	int mProtectedInt;
   	private:
    	int mPrivateInt;
};

새로운 클래스 Sub를 Super클래스로부터 확장하여 만들려면 Sub클래스를 정의할 떄 Super클래스를 지정해준다.

Class Sub : pulbic Super
{
	public:
    	Sub();
        void someOtherMethod();
};

Sub는 하나의 완결된 클래스로, 단지 Super클래스의 특징을 공유하고 있을 뿐이다. 위 코드에서 public이 무슨 의미인지는 나중에 설명한다. Sub과 Super의 관계를 나타내면 다음과 같다.

Sub가 Super의 유일한 파생 클래스일 필요가 없다.

 

제 삼의 클래스가 Super를 부모삼아 확장될 수도 있다. 이러한 파생 클래스들의 관계를 형제관계 라고 한다.


9.1.1.1 사용자 입장에서 본 상속

클래스를 사용하는 입장에서는 Sub클래스의 객체가 Sub타입이기도 하면서 Super타입이기도 하다. Sub가 Super를 상속받았기 때문이다. 이것의 의미는 Sub객체를 통해 Subpublic멤버와 메서드 뿐만 아니라 Super의 public멤버와 메서드도 이용할 수 있다는 것이다.

 

Sub객체를 통해 Super의 멤버와 메서드를 이용할 때 Super를 알아야 할 필요는 없다. 마치 Sub에서 Super의 멤버와 메서드를 직접 정의한 것처럼 이용 가능하다. 예를 들어 다음 코드에서 someMethod()는 Super에 정의된 메서드지만, Sub객체의 메서드인 것처럼 호출할 수 있다.

Sub mySub;
mySub.someMethod();
mySub.someOtherMethod();

상속은 단방향으로만 일어난다는 점을 알고 있어야 한다.

 

Sub클래스 입장에서는 Super클래스와의 관계가 분명하지만, Super클래스 입장에서는 자신을 상속받은 클래스가 무엇인지 전혀 알지 못한다.

 

즉, Super를 통해서는 Sub의 pulbic멤버와 메서드에 접근할 수 없다. Sub는 Super일 수 있찌만, (is-a관걔) Super는 Sub가 아니다.

 

다음 코드에서는 Super에서 Sub의 메서드를 호출하는 예로, 컴파일러에서는 정의되지 않은 메서드이기 때문에 오류 메시지가 발생한다.

Super  mySuper;
omySuper.someOtherMethod(); // 오류! Suepr에 someOtherMethod()가 정의되어있지 않다.

상속으로 구성된 클래스 객체를 사용하는 입장에서는 그 객체가 자식 클래스 타입으로도 보이고 부모 클래스 타입으로도 보인다.

 

포인터 또는 참조형으로 객체를 참조할 떄는 객체의 클래스는 물론, 부모 클래스 중 어떤 클래스 타입으로도 변수를 선언할 수 있다.

 

여기서 이해해야 할 개념은, Sub의 객체를 부모인 Super타입의 포인터로 참조할 수 있다는 것이다. 참조형 변수도 마찬가지다. Super타입의 포인터 또는 참조형 변수로는 Super의 데이터 멤버와 메서드만 이용할 수 있다. 하지만 Super객체를 대상으로 한 코드가 Sub 객체를 대상으로도 동작하는 점이 중요하다.

 

예를 들어 다음 코드는 타입 불일치가 발생할 것 같지만, 정상적으로 컴파일되고 작동한다.

Super* superPointer = new Sub(); // Sub타입 객체를 생성하여 Super타입 포인터 변수에 저장

하지만, Super타입 포인터로는 Sub타입에 정의된 메서드를 호출할 수 없다.

superPointer->someOtherMethod(); // Sub에 정의된 메서드를 Super 타입 포인터로 호출

컴파일러는 위 코드에 대해 정의되지 않는 메서드라는 에러 메세지를 발생시킨다. 컴파일러 입장에서는 Super만 보이기 떄문에, Sub에 someOtherMethod()가 정의되어 있는지 알 수 없다.


9.1.1.2 파생 클래스 입장에서 본 상속

파생 클래스 입장에서 보면 클래스를 정의하는 데 있어 일반 클래스와 별로 달라진 것이 없다.

일반 클래스에서와 마찬가지로 멤버와 메서드를 정의할 수 있다. 앞서 보인 Sub클래스에서는 someOtherMethod()메서드를 만들어서 Super클래스를 확장했다.

 

파생 클래스에서는 베이스 클래스에서 정의된 public, protected멤버와 메서드를 마치 자기 자신이 정의한 것처럼 사용할 수 있다.

 

사실 기술적으로도 파생 클래스 안에 정의된 것과 마찬가지다. 예를 들어 Sub의 메서드 someOtherMethod()를 구현할 때 베이스 클레스에 정의된 mProtectedInt 데이터 멤버를 이용할 수 있다. 베이스 클래스의 정의된 멤버나 메서드에 접근하는 것은 파생 클래스 자체에 정의되니 멤버나 메서드에 접근하는 것과 다를 바 없다.

 

void Sub::someOtherMethod()
{
	cout << "I can Access base Class data Member mProtectedInt." << endl;
    cout << "Its value is " << mProtectedInt << endl;
}

접근자를 처음 설명할 때는 private와 protected의 차이점이 모호했지만, 이제는 명확해졌다. protected멤버나 메서드는 파생 클래스에서 접근 가능하지만, private멤버나 메서드는 파생 클래스에 접근 불가능하다.

 

someOtherMethod()메서드 구현부는 Super의 private멤버에 접근하기 때문에 컴파일 오류가 발생한다.

void Sub::someOtherMethod()
{
	cout << "I can Access base Class data Member mProtectedInt." << endl;
    cout << "Its value is " << mProtectedInt << endl;
    cout << "The vlaue of mPriavteInt is" << mPiravetInt << endl; // 오류!
}

private 접근자를 이용하면 나중에 있을 지도 모를 파생 클래스와의 연동 수준을 어디까지 맞출 지 조절할 수 있다. 기본적으로 모든 데이터 멤버를 private로 해두기를 권장한다. 데이터 멤버에 접근이 필요하다면 get과 set메서드를, public 또는 protected로 선언한다. 데이터 멤버를 기본적으로 private로 두는 이유는, 그렇게 하는 것이 가장 높은 수준의 추상화 이기 때문이다.

 

즉, public이나 protected로 된 인터페이스를 변경하지 않으면서 데이터의 표현 방식을 자유롭게 바꿀 수 있다.

데이터 멤버에 대한 직접 접근을 허용하지 않으면서도 public이나 protected세터 메서드에 입력값에 대한 검사 기능을 쉽게 추가할 수 있다.

 

사실 메서드도 기본적으로 private로 선언하는 것이 좋다.; 단, 설계 차원에서 외부에 공개되어야 하는 메서드는 public으로 선언하고 파생 클래스에서 접근 가능해야 하는 메서드는 protected로 선언한다.

파생 클래스 관점에서는 베이스 클래스 멤버와 메서드 중 public또는 protected로 선언된 것만 이용 가능하다.


9.1.1.3 상속 방지

C++에서는final키워드를 통해 파생 클래스로 확장이 불가능한 클래스를 정의할 수 있다.

final로 정의된 클래스를 상속받으면 컴파일 오류가 발생한다.

 

final키워드는 클래스 이름 바로 뒤에 붙여서 사용한다.

.class Super final
{
}

다음의 Sub클래스 정의는final로 정의된 Super클래스를 상속받기 때문에, 컴파일 오류가 발생한다.

class Sub : public Super
{
}

9.1.2 메서드 오버라이딩

클래스를 상속받는 이유는 기능을 추가하거나 바꾸기 위해서다. Sub의 정의는 부모 클래스에 새로운 메서드 someOtherMethod()를 추가한다. 다른 메서드 someMethod()는 Suber로 부터 상속받은 것이고, 당연하게도 베이스 클래스에서와 완벽히 같게 동작한다.

 

하지만 많은 경우 상속을 받으면서 베이스 클래스에 이미 정의되어 있는 메서드의 행동을 바꾸어야 할 떄가 있다.

이것을 메서드 오버라이딩 이라고 한다.


9.1.2.1 메서드 오버라이딩과 virtual 속성

C++는 메서드 오버라이딩 특성을 제어하는 virtual이라는 키워드를 제공한다. 이 속성은 프로그래머에게 다소 혼란스럽다. 메서드 오버라이딩이 동작하려면 베이스 클레스에서 해당 메서드가 virtual로 선언되어 있어야 한다. 이 키워드는 다음처럼 메서드 선언 앞에 놓는다.

class Super
{
	public:
    	Super();
        virtual void someMethod();
    protected:
        int mProtectedInt;
    private:
        int mPrivateInt;
};

virtual 키워드는 잘못 디자인된 C++의 기능으로 이야기될 떄가 많다. 혼란을 피하는 가장 편한 방법은 모든 메서드마다 virtual을 붙이는 것이다. 그렇게 하면 지금 호출하는 메서드가 오버라이딩이 될지 안 될지 복잡하게 클래스 계층을 따라서 virtual선언 여부를 뒤져보지 않아도 된다.

 

단, 이렇게 하면 성능에서 약간 손해를 보게 된다.

 

Sub 클래스가 부모 역할을 할 일이 없다고 하더라도, 다음처럼 메서드 선언에 virtual선언을 넣어두는 것이 안전하다.

class Sub : public Super
{
	public:
    	Sub();
        virtual void SomeOtherMethod();
 };

virtual 을 빠뜨렸을때 발생하는 모호한 문제들을 피하기 위해서 소멸자를 포함해서 모든 메서드를 virtual로 서언하는 것이 좋다. 그러나, 생성자는 virtual로 선언할 수 없다.


9.1.2.2 메서드 오버라이딩 문법

메서드를 오버라이딩 할 떄는 베이스 클래스의 메서드를 파생 클래스에서 똑같이 선언해주고, 파생 클래스의 구현부에서 해당 메서드를 새롭게 정의한다.

 

예를 들어 Super클래스의 메서드 someMethod()가 super.cpp에 다음과 같이 정의되어 있다고 가정하자.

void SUper::someMethod()
{
	cout << "THis is Super's version of someMethod(). " << endl;
}

virtual 키워드는 클래스에서 메서드 프로토타입을 선언할 떄만 사용하면 되고 구현부에서는 사용할 필요가 없다.

 

someMethod()를 Sub클래스에서 오버라이딩 하기 위해서는 다음처럼 Sub클래스의 정의에서 오버라이딩할 메서드를 선언해주어야 한다.

class SUb : public Super
{
	public:
    	sub();
        virtual void someMethod() override; // SUper메서드 someMethod()를 오버라이딩
        virtual void someOtherMethod();
};

오버라이딩하는 메서드 뒤에 키워드 overide를 둘 것을 권장한다.

 

메서드나 소멸자가 한 번일다ㅗ virtual로 선언되고 나면 파생 클래스에서 오버라이딩 할 때 virtual 사용 여부와 관계없이 무조건 virtual이 적용된다.

 

예를 들어 Sub클래스는 someMethod()를 virtual 선언 없이 오버라이딩 하고 있지만, 베이스 클래스 Super에서 이미 virtual로 선언했기 떄문에, 자동으로 virtual이 적용된다.

class Sub ; public Super
{
	public:
    	Sub();
        void someMethod() override; // SUper클래스의 someMethod()를 오버라이딩
};

9.1.2.3 사용자 입장에서 보는 오버라이딩된 메서드

위 예제와 같이 someMethod()메서드가 오버라이딩 되더라도 사용자로서 메서드 호출 방법이 달라지는 것은 없다. 이전처럼 Suepr나 Sub 클래스의 객체에서 메서드를 호출하듯이 하면 된다. 단, someMethod()의 정의가 클래스에 따라 바뀌기 때문에, 어떤 클래스의 객체에서 메서드를 호출했느냐에 따라 행동이 다를 수 있다.

 

예를 들어 다음 코드는 이전과 동이랗게 super의 someMethod()가 호출된다.

SUper mySuper;
mySuper.someMethod(); // Super에 정의된 someMethod() 호출

위 코드의 실행 결과는 다음과 같다.

This is SUper's version of someMethod()

만약, 다음처럼 Sub객체에서 someMethod()를 호출하면,

Sub mySub;
mySub.someMethod(); // SUb에 정의된 someMethod()호출

다음과 같이 오버라이딩된 메서드가 호출된다.

This is Sub's version of someMethod().

 

오버라이딩 되지 않은 다른 메서드들은 이전처럼 Super와 Sub각각에 정의된 메서드가 호출된다. 예를 들어 오버라이딩 되지 않은 Super의 메서드라면 Sub객체에서 호출하더라도 SUper의 구현부에서 정의한 메서드가 동작한다.

 

앞서 배운 것처럼 포인터나 참조를 통해서 클래스나 그 클래스의 파생 클래스를 가리킬 수 있다. 이때 포인터나 참조가 어떤 타입인지와 관계없이 객체 스스로 자신을 생성한 클래스를 알기 때문에, virtual로 선언된 메서드의 호출이 일어나면, 가장 마지막 파생 클래스에서 오버라이딩된 메서드를 찾아서 호출해준다.

 

예를 들어 아래 코드에서 someMethod()를 호출하는 참조형 변수 ref는 Super 클래스 타입이지만, Sub타입 객체를 가리키고 있기 때문에 Super가 아닌, Sub에서 오버라이딩된 someMethod()를 호출한다.

 

이러한 오버라이딩 기능은 베이스 클레스에서 해당 메서드에 virtual키워드를 빠트릴 경우 올바르게 작동하지 않는다.'

Sub mySub;
Super& ref = mySub;
ref.someMethod(); // Sub에서 오버라이딩된 someMeThod() 호출

포인터나 참조가 베이스 클래스의 타입이면, 실제 가리키는 객체가 파생 클래스라 하더라도, 파생 클래스에만 정의된 멤버나 메서드에는 접근할 수 없다는 점을 기억하라.

 

예를 들어 아래 코드는  Super타입의 참조형 변수로 SUb에만 정의되어 있는 someOtherMethod()를 호출하려 해서 컴파일 에러가 발생한다.

Sub mySub;
Super& ref = mySub;
mySub.someOtherMetod(); // 문제 없음
ref.someOtherMethod(); // 오류!

이렇게 객체 스스로 자신이 생성된 클래스를 기억해서 가리키는 클래스 타입과 관계없이 메서드 오버라이딩을 반영하는 동작은 포인터나 참조형 변수에서만 발생한다.

 

객체 간 타입 캐스팅이나 대입을 통해 객체가 변경되는 경우에는 원본 객체의 클래스 정보가 기억되지 않는다.

Sub mySub;
Super assignedObject = mySub; // Sub객체를 Super객체에 대입
assignedOPbject.someMethod(); // Super클래스에서 정의한 someMethod()가 호출됨

이러한 동작 방식은 다소 혼란스럽다. 이것을 가장 쉽게 이해하는 방법은 메모리에 객체가 할당된 상태를 생각해보는 것 이다.

 

Sub객체는 Super객체보다 메모리 영역을 조금 더 많이 차지한다. Sub만의 특성을 추가로 가지고 있기 떄문이다. 포인터나 참조로 Sub객체를 가리키고 있을 떄 그 타입의 Super라 하더라도 메모리를 차지하고 있는 크기는 변하지 않는다.

 

즉, Super타입으로 참조하고 있어도 그 객체인 Sub의 특징들은 메모리에 유지된다. 반면 SUb객체를 Super객체로 캐스팅하면 Sub 객체만의 특징이 없어지고, Super객체로서의 데이터만 복제된다. 즉, 차지하고 있는 메모리 크기가 Sub에서 Suepr로 작아진다.


9.1.2.4 오버라이딩 방지

C++에서는 메서드를 final로 선언하여 파생 클래스에서 오버라이딩 되지 않도록 할 수 있다.

final로 선언된 메서드를 오버라이딩 하려 하면 컴파일러가 에러 메시지를 발생시킨다. 예를 들어 SUper클래스가 다음과 같이 메서드를 final로 선언하였고,

class Super
{
	pulbic:
    	Super();
        virtual void SomeMethod() final;
};

다음과 같이 Sub에서 그 메서드를 오버라이딩하려 하면 컴파일 에러가 발생한다.

class Sub : public Super
{
	public:
    	Sub();
        virtual void someMethod() override; // 컴파일 에러 발생
        virtual void someOtherMethod();
};

 

728x90