상속과 조합

상속의 위험성

상속은 중복코드를 없애고 코드 재사용을 하기 위해 등장한 개념이다.
내가 필요로 하는 클래스와 매우 유사한 클래스가 있다면 해당 클래스를 상속하면 같은 코드를 여러번 쓸 필요가 줄어든다.

상속은 코드 재사용을 위해 캡슐화를 희생시킨다. 부모 클래스의 코드를 재활용하기 위해 자식 클래스도 재활용할 코드를 모두 공개되기 때문이다.

경고 1. 자식 클래스 메서드 안에서 super 참조로 부모 클래스의 메서드를 직접 호출하면 두 클래스의 결합도가 높아진다. (super 참조를 제거하라.)

상속을 염두해두고 설계하지 않은 클래스를 상속하기 어렵다.
기존의 코드를 다른 상황에 재사용하기 위해서는 개발자가 나름의 해석을 하고 가정한다.
그러나 개발자의 가정을 이해해야 하는 코드는 읽기가 어려워진다.

실제 요구사항과 구현이 다르면 우리는 기존의 구현을 요구사항과 같은 결과를 나타내도록 많은 가정을 하게된다.

상속을 하려면 부모 클래스의 가정과 추론 과정을 정확하게 이해해야 한다.
상속은 부모 클래스의 구체적인 구현을 이해해야 할 가능성이 높다. 그래서 결합도(다른 모듈에 대해 알고 있어야할 지식의 수준)가 높다.

자식 클래스가 부모 클래스의 변경이 취약해진다. 취약한 기반 클래스 문제.
만약 부모 클래스의 변경된 부분이 자식 클래스에 파생되는 행동이라면, 자식 클래스의 행동이 예상과 다르게 작동 될 수 있다.

상속은 자식 클래스가 부모 클래스의 구현 세부 사항에 의존하도록 만든다.
상속은 부모 클래스의 퍼블릭 인터페이스가 아닌 다른 곳을 고쳐도 영향을 받는다. 이게 캡슐화를 약화시킨다는 증거다.

경고 2. 상속 받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

코드 재활용을 하기 위해 부모 클래스를 상속 할 경우, 부모에서 공개 됐던 메서드들이 자식 클래스의 내부 규칙을 깨트릴 수 있다.

오브젝트에서 제시한 예시는 Vector를 상속받는 Stack이다.
Stack은 push와 pop으로만 데이터를 수정할 수 있다. 그러나 Vector는 퍼블릭 인터페이스로 다양한 인덱스에 add할 수 있게한다. 이는 Stack의 규칙을 깨트리는 방식이다.

1
2
3
4
5
6
Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");

stack.add(0, "4th"); //이건 Vector의 퍼블릭 인터페이스. 스택의 규칙에 어긋난 행동

경고 3. 자식이 부모의 메서드를 오버라이딩하면 부모의 다른 메서드가 자식의 메서드를 결합하게 될 수 있다.

부모 클래스의 특정 메서드를 자식 클래스가 오버라이딩 하게 되면, 부모 클래스에서 해당 클래스를 원래의 메서드(즉 오버라이딩 되기 전 메서드)를 예상하고 사용했던 부분에서 예기치 못한 작동(오버라이딩 된 메서드)를 수행할 수 있다.

경고 4. 부모 클래스에 변화가 생기면 자식도 같이 변해야 되는 일이 생길 수 있다. 상속은 결합도가 높아서, 부모와 자식이 같이 변하거나, 자식과 부모를 변경하지 않거나 두가지 선택지 밖에 없다.

부모 클래스를 오버라이딩 하지 않고 불필요한 퍼블릭 인터페이스를 상속받지 않아도 부모의 중요한 부분이 변경되면 자식 클래스도 같이 변경될 수 밖에 없다.

상속을 보다 안전하게 사용하기

추상화에 의존

자식 클래스가 부모 클래스의 구현이 아닌 부모 클래스의 추상화에 의존해야 한다.
더 정확하게 말하면 부모와 자식 모두 추상화에 의존해야 한다.

달라지는 부분을 찾아 메서드로 분리

비슷해보이는 클래스 간에 서로 다른 부분을 메서드를 추출한다.
그러면 다른 부분을 제외하면 나머지 메서드들은 동일하게 된다.
그러면 이제 중복 코드를 부모 클래스로 올린다.
그리고 달라지는 부분은 부모의 추상 메서드로, 각 클래스들이 구현하도록 하면 된다.

이제 자식 클래스들은 부모의 추상 메서드에만 의존하게 되므로 느슨하게 결합된다.

하지만 이런 추상 클래스를 활용한 추상화에 의존하는 방법도 문제가 있다.
객체 행동만 변하면 각 클래스를 독립적으로 변경시키면 되겠지만, 인스턴스 변수가 추가되는 경우는 다르다.
부모 클래스에 인스턴스 변수가 추가되어 객체 생성때 초기화해줘야 하는 경우, 상속되는 모든 클래스는 수정을 해줘야 한다.

합성을 활용하기

상속은 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는 방법.
합성은 전체를 표현하는 클래스가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.

합성은 구현에 의존하지 않는다.
합성은 내부 부분 객체의 퍼블릭 인터페이스에 의존한다. 즉 구현에 의존하는 것이 아닌 인터페이스에 의존하도록 할 수 있다.
합성은 내부 부분 객체의 구현 방식이 변경되어도 전체 객체는 영향이 적다.

상속은 정적인 관계인데 합성은 동적인 관계이다.
코드 작성 때 정해진 상속 관계를 실행 시점에서 변경이 불가능하다.
반면 합성 관계는 코드 작성 때 정해진 관계를 실행 시점에서 변경이 가능하다.

상속을 합성으로 바꾸는 방법은 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하면 된다.

합성의 안전성

불필요한 인터페이스 상속 문제
부모 클래스의 인스턴스를 외부에서 접근하지 못하도록 하고,
자식 클래스의 규칙에 맞게 인스턴스의 퍼블릭 인터페이스를 활용하면 된다.

메서드 오버라이딩 오작동 문제
부모 클래스의 인스턴스를 외부에서 접근하지 못하도록 하고,
자식 클래스의 메서드가 부모 인스턴스의 메서드를 오버라이딩해서 부모 인스턴스의 퍼블릭 인터페이스와 협력하면 된다.
(이때 오버라이딩한 인스턴스 메서드가 인스턴스에게 동일한 메서드 호출을 전달하는 메서드를 포워딩 메서드라고 한다.)

부모 클래스와 자식 클래스의 동시 수정 문제
합성이 이 문제를 완전히 해결하지는 못한다. 하지만 조합된 내부 인스턴스의 변경사항을 최대한 캡슐화 시킬 수 있다.

합성의 유연성

하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 함.

단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 많이 늘어날 수 있다.

추상 메서드와 훅 메서드
추상 메서드 : 개방 - 폐쇄 원칙을 만족하기 위한 설계. 하위 계층이 오버라이딩해서 구현
훅 메서드 : 추상 메서드는 반드시 모두가 구현해야 해서 불편하다. 대부분의 하위 계층이 똑같이 구현하면 중복 코드가 많이 생기기 때문에 기본 구현을 해놓고 달라지는 경우에만 오버라이딩하는 메서드

기능 추가를 할 때 상속을 남용하면 필요 이상으로 클래스가 만들어진다.(클래스 폭발, 조합의 폭발)
이런 문제는 자식 클래스와 부모 클래스의 다양한 조합이 필요한데 상속은 컴파일 타임에 관계가 결정되어 버려서 모든 조합을 미리 만들어놓으면서 생기는 문제다.

합성을 사용하면 컴파일 타임에서 정한 관계를 런타임에서 수정할 수 있다.
상속은 조합의 결과를 개별의 클래스에 밀어넣는 방법이고, 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 다음 런타임에서 인스턴스를 조립하는 방식이다.

그러면 상속은 언제 사용하는가

코드 재활용을 목적으로 상속하면 변경하기 어렵고 유연하지 못하게 된다.
만약 상속을 사용하려고 할 때 스스로에게 물어보자.
내가 상속을 이용하는게 코드 재사용을 위한 것인가? 클라이언트 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위한 것인가? (코드 재사용을 위한 것이면 상속을 피해야 한다.)

Share