상속(프로그래밍)

덤프버전 :



파일:attachment/displayobject_racoon28.jpg
위 그림은 액션스크립트 3.0에서 기본적으로 지원하는 클래스간의 상속 구조도. OOP 언어라면 (당연히) 시스템을 제공하는 사람들도 이렇게 객체지향 구조를 이용한다.

1. 개념
2. 사용 예
2.1. 재정의
2.2. 직접 - 간접 상속
2.3. 다중 상속
2.3.1. 죽음의 다이아몬드
3. 문제점과 대안
3.1. 정보 은닉의 파괴
3.2. 동적인 유연성이 떨어짐
3.2.1. 결합도를 크게 늘림
3.3. 엉뚱한 상속구조의 발생
3.4. 부모 자리에 자식이 들어가더라도 정확히 같은 행동을 할까?
3.5. 동적 바인딩에서 오는 성능 하락



1. 개념[편집]


객체 지향 프로그래밍(OOP: Object Oriented Programming)에서 크게 3요소로 꼽는 캡슐화, 상속, 다형성 세 가지 중 상속을 일컫는다. 다른 표현으로는 계승, 확장[1]이라는 단어도 사용된다. 일본,중국,북한에서는 계승이라는 단어로 번역했다. 영어로는 inheritance 라고 한다. 관계로는 A is a B(A는 B다)라고 표현한다.[2] 이산수학과 연계된다.

파생 클래스가 기초 클래스의 기능을 받아 쓰는 것이라고 이해하면 쉽다. 이럴 땐 파생 클래스는 기초 클래스의 기능을 받았으므로 기초클래스의 기능도 쓸 수 있게 된다.[3]

상속하는 클래스는 '슈퍼 클래스', '부모 클래스' 등으로 부르고, 상속받는 클래스는 '서브 클래스', '자식 클래스' 등으로 부른다.

CSS에서 말하는 상속[4]과는 다른 개념이다.

2. 사용 예[편집]


철권 시리즈를 구현한다고 치자. 그러면 당신은 카즈야헤이하치 같은 캐릭터의 클래스를 정의할 것이다. 만약 당신이 초보 프로그래머[5]라면

class 카즈야
{
    체력
    힘
    스피드 
    풍신권
    나락쓸기
    ...
}

class 헤이하치
{
    체력
    힘
    스피드
    뇌신권
    ...
}

대충 이런 식으로 캐릭터마다 클래스를 만들어낼 것이다.

하지만 잘 들여다보면 카즈야나 헤이하치는 상당히 많은 공통점을 갖고 있다. 일단 머리, 팔, 다리가 기본적으로 있고 수치상으로 따지고 들어가면 체력과 공격력, 방어력, 회피율 등의 수치가 있다. 쿠마모쿠진처럼 인간이 아닌 캐릭터와 비교해 봐도 이러한 공통점을 찾을 수 있다. 요컨대 격투게임 캐릭터는 저마다 기술이나 인종은 다를지 몰라도 공통적으로 체력과 힘, 스피드 같은 요소들을 갖추고 있다는 것이다.

그렇다면 아래와 같이 "캐릭터"라는 클래스를 만든 다음에

class 캐릭터
{
    체력
    힘
    스피드
    ...
}

이렇게 공통되는 부분을 클래스로 만든 다음에

class 카즈야 : public 캐릭터
{
    풍신권
    나락쓸기
    ...
}

이렇게 상속을 받아 개별 캐릭터를 정의해 주면 똑같은 클래스를 몇번이고 다시 쓰는 수고를 하지 않아도 된다. 때문에 클래스는 설계도나 금형, 붕어빵틀에 곧잘 비유되곤 한다. 이러한 비유도 썩 적절하지는 않은데, 상속은 여기서 더 나아가 기존 클래스에 새로운 기능을 얼마든지 덧붙이거나 심지어 기존 클래스까지 새롭게 정의할 수 있기 때문이다.

만약 클래스나 상속을 쓰지 않았다면 사소한 수정 사항이라도 발생할 경우 모든 캐릭터의 코드를 일일이 찾아다니며 수정을 해야 하지만 상속을 쓰면 그냥 상속 받은 클래스에 새로운 변수를 하나 추가하거나 아래의 재정의 같은 기능을 이용해서 입맛에 맞게 바꿔줄 수도 있다.

2.1. 재정의[편집]


상속받은 파생 클래스가 부모 클래스의 요소를 재정의해야 할 필요가 있을 것이다. 이를테면 오우거진파치의 체력을 두 줄로 해주세요, 로저의 경우는 캥거루 꼬리를 다리처럼 쓰는 캐릭터니 다리가 2개가 아니라 3개의 다리가 있는것처럼 만들어주세요...같은 요구사항이 있을 경우에는 재정의를 사용한다. 영어로는 override 오버라이드라고 한다.

재정의를 할 수 있는 것은 멤버 함수에 한정되며, 이 때문에 함수(또는 연산자) 오버로딩overloading과 종종 혼동되므로 주의해야 한다. 함수 오버로딩은 함수(메소드) 이름만 같고 인수 개수나 타입이 달라 서로 구별할 수 있는 서로 다른 메소드를 만드는 것을 말한다. 혼동이 우려되면 재정의라는 말을 쓰거나 method overriding 이라고 하자.

부모 클래스의 오버로딩된 메소드가 여러 개 있는데 그중에 일부만 재정의하면 나머지 메소드는 가려져 호출할 수 없다. 부모의 기능 중 하나만 오버라이드 해도 될 때도 다른 메소드도 전부 재정의하자. 그냥 부모의 메소드만 호출해 반환하는 코드 한줄이면 된다.


2.2. 직접 - 간접 상속[편집]


한 번 파생받은 클래스에서 또 파생되는 경우, 파생 클래스 바로 위의 클래스를 직접 클래스(direct class), 그 위의 클래스를 간접(indrect) 클래스라고 칭한다. 어느정도 규모가 되는 실 프로젝트에서는 위로 끝없는 클래스의 계층이 펼쳐져 있고 엄청난 수의 간접 클래스와 인터페이스가 가득하다.


2.3. 다중 상속[편집]


한 번에 둘 이상의 클래스를 파생받는 경우, 다시 말해 여러 부모를 둔 경우를 두고 다중 상속(multiple inheritance) 이라고 칭한다. 이 방식의 장점은 매우 직관적이라는 것. 하지만 사실 별로 권장되는 방법은 아닌데, 바로 밑의 '죽음의 다이아몬드' 때문에 일반 클래스를 다중 상속하는건 극히 꺼려지며 인터페이스 용도의 클래스에서만 상속받는게 일반적이다. 사실 다중 상속은 이런저런 것으로 대체 가능하므로 인터페이스 다중 상속을 제외하고 다중 상속되는 상황 자체가 이미 막장.

Java와 C#은 아예 문법적으로 다중 상속을 하지 못하도록 막아 놓았다. 그나마 예외로는 C++의 Signal/Slot이나 GUI에서 부모 자식 관계를 이용한 자동 메모리 관리와 기본 GUI 컴포넌트의 상속, 또는 Qt(프레임워크)의 QObject 정도가 있다. C++ 이외에 파이썬도 다중상속이 가능하다.

멤버변수가 있는 클래스를 다중상속했을 경우 다중상속으로 인해 2번째 이후로 오는 클래스는 첫 번째 클래스 다음 순서로 메모리에 올라오기 때문에 원본 클래스와는 메모리 위치가 다르며, 이에 대한 메모리 주소 처리를 요구하기 때문에 추가적인 오버헤드가 발생한다.

2.3.1. 죽음의 다이아몬드[편집]


파일:svvHZGt.jpg

The Deadly Diamond of Death(DDD)

C++에서 super 키워드가 없는 이유.[6]

프로그래밍 언어컴퓨터에게 내릴 명령을 순서대로 정리해 놓은 문서라고 볼 수 있으며, 가장 중요한 특징 중 하나는 같은 구문이 두 가지 이상의 의미로 해석될 여지가 있어서는 안 된다[7]라는 것이다. 그런데 다중 상속이 허용될 경우 이러한 상황이 발생할 가능성이 있으며, 그 중 한 예가 바로 위의 그림과 같은 "죽음의 다이아몬드"이다.

예를 들어 '사람'이라는 기본 클래스가 있고, 여기에는 '성격()'과 '키()' 메소드가 있다. 이제 '사람' 클래스를 상속받은 '아빠'와 '엄마' 클래스가 있고, 이 둘을 동시에 상속받은 '자식' 클래스가 있다고 하자. 이렇게 되면 '자식' 클래스에서 '성격()' 혹은 '키()' 메소드를 호출할 때 어느 부모의 메소드를 따라야 하는지가 한 가지로 명확히 정해지지가 않는다. 예컨대 프로그래머는 '아빠'의 "키()" 메소드를 따르고 "엄마"의 "성격()" 메소드를 따르는 것을 의도했지만 전혀 의도하지 않은 정반대의 결과물이 나올 가능성이 존재한다는 것. 이것이 바로 '죽음의 다이아몬드'이다. 상속 관계가 마름모꼴(다이아몬드 형)으로 생겼다고 해서 붙여진 이름.

C#(.net)하고 Java에선 이것을 방지하기 위해 다중상속 자체를 제한해 놨다. 상속은 기본적으로 일반 클래스는 무조건 하나만 가능하다. 두개의 이상의 클래스를 상속받는 상황을 보조하기위해 추상메소드라는 개념이 있다. 다만 이름과 의미만 정해져 있고 실제 내용없는 빈 깡통으로 정의되며, 여차하면 내용을 참고하지 않으므로 문제를 일으키지 않는다. 예를들어 인터페이스는 추상 메소드만 모아놓은 것이기 때문에 실질적으로 쓰이는 것은 하위 클래스에서 실질적으로 구현된 유일한 메소드 뿐이지, 단순히 메소드 이름만 가진걸로는 아무련 효과가 없다. 즉 실제 내가 무언가 만들기 전까지 아무런 일도 하지 않으므로 죽음의 다이아몬드고 뭐고 뭘 호출할지 모호함이 발생할 여지를 만들지 않는 개념이다.

물론 추상 메소드만 쓰는 인터페이스 특성상 클래스마다 똑같은 기능을 매번 구현해야 하고 이로 인해 코드의 재사용성이 크게 저하되는 문제점이 있다. 결국 이름만 지정된 아무것도 아닌 메소드를 구현하는건 다른클래스에 똑같은걸 그대로 구현하는것과 실질적으로 같은 모양새인 셈.

이 문제 때문에 최근에 나오는 언어들은 트레이트나 믹스인 같이 일반 메소드가 있어도 다중 상속이 가능한 모델을 고안하고 있다. 위의 죽음의 다이아몬드에 대한 해결책도 새로 나온게 있는데, 바로 상속의 우선순위를 두는 것이다. 앞에 오는 클래스의 메소드만 상속하고 그 외의 클래스의 메소드는 무시해 버리는 것. Python이 이런 방식으로 구현되어 있다. 기존의 다중 상속 방식이 수학적으로는 클래스의 집합 위에 정의해야 했다면, 이런 방식은 클래스의 수열 위에 정의하였다고 볼 수 있다. 이런 경우 앞의 모호함 문제는 해결되지만 상속되는 클래스들의 순서가 바뀌면 가변하는 부분도 있는 등, 프로그래머가 생각해야 할 부분이 늘어난다. 아니면 아예 상속을 받지 않는 조상 객체만 다중 상속이 가능하게 해도 공통 조상이 존재할 수 없어지기 때문에 죽음의 다이아몬드를 방지하는게 가능하다.

3. 문제점과 대안[편집]


객체지향에서 없어서는 안될 3요소중 하나인 상속이지만[8] 왠지 모르게 단점과 허점이 있어서 폭풍처럼 까이고 있기도 하다. 요즘은 다형성을 위해 써야 할 때가 아니면 가급적 구성[9](composition over inheritance)을 사용하길 권장하고 있다.


3.1. 정보 은닉의 파괴[편집]


하위 클래스가 상위 클래스의 정보를 뜯어내 보안상 허점을 만들어낼 수 있다. 이를 방지하기 위해 자식한테도 안 보여줄 것은 private, 자식에겐 보여주되 남에게는 안 보여줄 건 protected, 누구에게나 다 보여줄 건 public으로 선언하도록 구분하는 기능이 기본적으로 지원되지만, 문제는 protected를 미칠듯이 남용해대는 프로그래머가 많다는 것.[10] 특히 객체지향에 갓 입문한 초보 프로그래머의 클래스엔 온통 protected와 public만이 가득한걸 볼 수 있다.


3.2. 동적인 유연성이 떨어짐[편집]


상속은 컴파일 시점에 부모를 지정해 놓으면 런타임 시점에 바꾸는 방법이 없다시피 하므로 유연성이 바닥이다. 동적인 슈퍼클래스 바꾸기를 지원하는 언어가 아주 없는 건 아니지만 그런 걸 지원했다가 발생할 수 있는 위험 요소가 한두가지가 아닌지라 거의 없다고 봐도 무방하다.


3.2.1. 결합도를 크게 늘림[편집]


좋은 객체지향적 코드는 한 클래스 내 요소들의 응집도는 높이고, 서로 다른 클래스 간의 결합도는 떨어뜨린 코드이다. 그런데 어떤 클래스가 상속으로 부모자식 관계가 되면, 그 부모 클래스는 몰라도 자식 클래스는 부모 클래스 없이는 그야말로 아무것도 아닌 클래스가 된다. 이는 자식(이 될) 클래스의 부모(가 될) 클래스에 대한 결합도를 크게 높이고, 이에 따라 객체지향의 핵심중 하나인 재사용이 힘들어진다. 상속하는 순간 그냥 한 세트가 된다고 생각하면 편하다. 이건 포함도 비슷하지만, 최소한 포함은 최상위 클래스의 인터페이스만 알면, 그와만 결합될 뿐이고 서브클래스는 아웃 오브 안중이 된다.


3.3. 엉뚱한 상속구조의 발생[편집]


특정 기능이 필요하기는 한데 상식적으로 전혀 is a 관계가 아님에도 불구하고 억지로 상속을 사용하면 괴랄한 상속구조가 탄생한다. '개그맨' 클래스를 구현하는 것을 예로 들어 보자. '개그맨' 클래스의 구현을 위해 '사람' 클래스를 상속받았을 경우 "'개그맨' is a '사람'"이라는 관계가 성립하며, 이는 누가 봐도 쉽게 납득할 수 있다. 그런데 '훔치기() 기능을 탑재한 개그맨을 구현할 필요가 생기자 개그맨 클래스가 '사람' 클래스 대신 '도둑' 클래스를 상속하게끔 수정하면 "'개그맨' is a '도둑'"이라는 아스트랄한 관계가 탄생한다. 즉, 멀쩡한 개그맨까지 죄다 도둑 취급을 받게 된다. 그러므로 상속은 인터페이스와 부모와 자식 간 관계(A is a B-A는 B인가?)를 고려해서 상속하는 것이 확실한 경우에만 사용하고 아닐 경우에는 가급적 포함을 사용하는 것이 좋다.


3.4. 부모 자리에 자식이 들어가더라도 정확히 같은 행동을 할까?[편집]


is a 관계가 확실하다고 하더라도 문제가 발생할 소지는 남아있다. 상술했듯이 자식 클래스는 부모 클래스에 명시된 어떤 행동(메소드, 함수)을 물려받아 그대로 쓸 수도 있고, 재정의(override)할 수 있다. 여러 이유로 인해 자식 클래스의 행동이 보여주는 결과나 결과가 가지는 조건, 의미 등이 부모 클래스 때와는 딴판으로 달라질 수도 있다. 만약 자식 클래스가 부모 클래스 메소드를 물려받아 부모 클래스가 하던대로 똑같이 동작만 해준다면 다른 객체에서 "부모 클래스 객체의 어떤 메소드를 사용한다"는 자리에 자식 클래스 객체를 넣더라도 문제가 발생하지 않겠지만, 그렇게 되지 않을 가능성도 얼마든지 있다.

예를 하나 들어보자. "사람"이라는 기본 클래스가 있고 여기에는 "악수" 메소드가 있다. 즉, "사람" 클래스를 상속받은 모든 클래스는 "악수"를 할 수 있는 것이다. 보통 사람들이라면 악수를 하는 걸로 상대에게 해를 끼치는 경우는 거의 없다. 그런데 "사람" 클래스를 상속받은 "가위손 에드워드" 클래스가 있다고 하자. 에드워드도 일단은 "사람" 클래스를 상속받았으니 당연히 악수를 할 수 있지만 손이 가위로 돼 있기 때문에 다른 사람이 에드워드의 손을 잡으면 다치는 상황이 발생하는 것이다. 그러나 많은 프로그래밍 언어는 "에드워드"가 "사람" 클래스고 "악수"를 할 수 있다는 정도를 따지지, 손 대신 나오는게 위험한 가위손일 수도 있다는 점까지는 미리 따지지 못한다. 프로그래밍 언어 기준으로 말하자면 사람 클래스 + 악수 메소드 기준으로 만들어졌던 예전 코드들에 가위손 클래스의 객체를 넣으면 예전 코드에서 악수 메소드를 잘 쓰던 부분이 몽땅 망가질 수 있다는 뜻이다. 물론 프로그래밍 언어가 허용한다면 사람 클래스를 상속받은 어떤 클래스가 "악수" 행동을 할 때 가위손이 나오든 기관총을 발사하든 컴파일 오류는 안 나겠지만, 가위손과 같이 자신은 멀쩡한데 다른 쪽에서 망가지는 상황은 모두가 겪고 싶지 않을 것이다. 즉 논리적 오류가 난다. 상속을 할 수 있다고 하면 그만인 것이 아니라 다른 객체들을 고려해서 신중하게 해야 하는 이유이기도 하다.

상술된 사람과 가위손의 악수 차이를 학문에서 표현한 것이 리스코프 치환 원칙(Liskov Substitution Principle)이다. 즉 S가 T의 하위형(subtype)일 때 필요한 프로그램의 속성[11]을 변경하지 않고도 자료형 T의 객체를 자료형 S로 교체할 수 있다면 원칙이 만족된다는 것이다.


3.5. 동적 바인딩에서 오는 성능 하락[편집]


상속을 사용하면, 필연적으로 오버라이딩을 사용할 것이다. 이 오버라이딩이야 말로 상속이 다형성을 가져다 주는 요인인데, 문제는 오버라이딩을 하려면 필연적으로 메소드(함수)가 동적으로 바인딩되어야 한다는 것이다. 실행될 메소드를 컴파일 타임에 결정하는 정적 바인딩 방식으로 구현할 경우, 부모클래스 또는 상위 인터페이스의 메소드 호출 시 자식 클래스의 메소드가 동작하지 않고 부모의 메소드가 호출될 것이다. 이는 아무런 다형성도 가져다 줄 수 없다. 때문에, 런타임에 오버헤드가 생기게 되며 이것은 객체지향 방식이 절차적 방식에 비해 느린 이유중 하나이다.



파일:크리에이티브 커먼즈 라이선스__CC.png 이 문서의 내용 중 전체 또는 일부는 2023-12-02 15:23:52에 나무위키 상속(프로그래밍) 문서에서 가져왔습니다.

[1] Java에서 사용한다(extends)...[2] 예를 들면 자작나무 is a 나무(자작나무는 나무다)라고 표현하는 것과 같다.[3] 하지만 파생된 클래스가 기초클래스의 노릇 못하는 경우가 종종 발생한다. 본 문서의 3.4 참조.[4] 부모 태그의 속성 중 상속이 가능한 CSS 속성을 그대로 자식 태그에게 넘겨 주는 기능[5] C++을 기준으로 작성하였다.[6] super는 자신의 상위 클래스를 나타내는 키워드다. 예를 들어, 만약 이 예시를 단일 상속이라는 가정하에 DigitalRecorder가 CDBurner만 상속받은 상태였다면 DigitalRecorder의 super.burn()은 CDBurner의 burn()을 호출했을 것이다. 그러나 C++은 다중 상속이 허용된 프로그래밍 언어라, 위의 예시대로라면 DigitalRecorder의 super.burn()은 CDBurner의 burn()인지 아니면 DVDBurner의 burn()인지 모호해지기 때문에 super라는 키워드가 C++에 존재했었다고 해도 모호성에 의해 컴파일을 실패했을 것이며, 이런 이유 때문에 super 키워드가 존재하지 않는다. 다만, C++을 사용하는 프레임워크라고 무조건 super 키워드가 없는 것은 아니다. 예를 들면, 언리얼 엔진은 C++을 스크립트 언어로 사용하는데도 super 키워드가 존재한다. 당연한 소리지만 이는 언리얼 엔진의 C++에서는 다중 상속을 허용하지 않는다는 의미다. 무엇보다도, C++은 다중 상속이 '허용'되어 있는 것 뿐이지, C++에서도 다중 상속을 하는 걸 추천하지 않는다.[7] 이를 프로그래밍 용어로 모호성 이라고 표현한다.[8] 심지어 3요소 중 하나인 '다형성'은 구현시 거의 대부분이 상속에 의존한다! 다형성 구현에 상향 형변환(upcasting, 부모 클래스 참조변수로 자식 클래스 객체 지칭)이 전제되고, 상향 형변환 구현에 동적 바인딩이 전제되고, 동적 바인딩 구현에 상속이 전제되기 때문이다.[9] 조합이나 합성 등으로도 알려져있기도 하다. 아직 표준어가 결정되지는 않은듯.[10] get()이나 set() 계열의 메소드를 구현해서 값은 private로 설정해 놓고 은닉성을 지킬 수 있다고 믿고 get과 set을 미칠듯이 남용해대는 프로그래머가 많은데, get과 set을 이용해서 클래스 내부에서 처리해야 하는 로직을 외부에서 구현하는 게 가능하고 이로 인해 단일 책임 원칙을 위반하며 은닉성이 떨어지게 된다. 값만 은닉한다고 정보 은닉이 지켜지는게 아니다. 가능하면 둘 다 쓰지 말고, 필요하다면 get만 쓰는것이 좋다.[11] OOP에서 '속성'은 데이터, 필드, 멤버 변수 등을 의미한다. '행위'는 메소드를 일컫는다.