객체지향 #8부 - 유연함을 위한 다형성과 타입 정의에서의 상속

객체지향 #8부 - 유연함을 위한 다형성과 타입 정의에서의 상속

객체지향 패러다임에서 다형성이 구현되는 기술적인 메커니즘을 살펴보고, 런타임시에 메세지를 처리하기 위해 메서드를 동적으로 탐색하는 과정을 위해 어떤 기법이 도입되는지에 대해 이해해보자.


다형성이란 무엇일까?

다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이다.
컴퓨터 과학에서는 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 여러 구현체를 연결할 수 있는 능력이다.

다형성의 종류 다형성의 종류
시범으로 gif로 만들어봤다. bytebytego 기다려!


다형성의 종류

  • 오버로딩 다형성 : 같은 이름의 메서드를 여러개 정의하고, 매개변수의 타입과 개수를 다르게 하여 매개변수에 따라 다르게 동작하도록 하는 기법
  • 강제 다형성 : 동일한 연산자를 다양한 타입에 사용할 수 있는 방식
    • EX ) String에서의 ‘+’ 는 문자열을 연결하는 연산자이지만, int에서의 ‘+’ 는 덧셈 연산자이다.
  • 매개변수 다형성 (제네릭) : 인스턴스 변수나 메서드의 매개변수를 임의의 타입으로 선언한 수 사용하는 시점에 구체적인 타입으로 지정하는 방식
    • EX ) List list = new ArrayList<>();
  • 포함 다형성 : 메시지가 동일해도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 것
    • 서브타입 다형성 이라고도 한다.



우리가 앞으로 얘기할 다형성은 포함 다형성이다.

포함 다형성

포함 다형성에 대한 구현 방법중에 하나로 상속 을 사용하곤 한다.
이 말은 상속 이외에도 포함 다형성을 구현할 수 있는 방법이 있다는 것이다.
런타임 시점에서 적절한 메서드를 선택하기 위해 어떠한 방식이 도입되어야 하는지를 알아보자.

다형성은 영어로 번역하면 Polymorphism 이다.
그리스 합성어로써 Poly(많은) + Morph(형태) 를 가질 수 있는 능력 이라는 뜻이다.




상속을 통해 다형성의 동작 방식 알아보기

타입 계층을 기반으로 한 다형석의 동작 방식을 이해하기 위해, 상속 방식의 속성들에 대해 알아보자.

  • 업캐스팅과 동적 바인딩
  • 다형성을 통한 동적 메서드 탐색
  • self 참조 vs super 참조



업캐스팅과 동적 바인딩

  • 업캐스팅 : 부모클래스 타입으로 선언된 변수에 자식 인스턴스를 할당하는 것이 가능하다.
    • 서브타입을 슈퍼타입으로 변환하는 것.
  • 동적 바인딩 : 실행될 메서드가 런타임에 결정되는 것.



자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있다.
이를 통해 부모 클래스와 협력하는 클라이언트는 서브클래스의 인스턴스를 사용할 수 있게 되고, 런타임시에 적절한 기능을 하는 메서드를 선택할 수 있게 된다.


다형성을 통한 동적 메서드 탐색

객체가 상속을 받아 생성되면, 모든 상속 계층 객체들에 대한 참조를 가지고 있다.
자바의 경우 컴파일 시점에 이미 없는 메서드에 대한 호출을 검증하기 때문에, 컴파일 시점에는 메서드가 존재하지 않는다면 에러를 발생시킨다.
런타임 시점에 자신 클래스에 정의되지 않은 메서드를 호출하면, 부모 클래스로 올라가서 메서드를 찾는다.

💡객체지향에서 실행할 메서드를 탐색하는 방법

  1. 메세지를 수신한 객체에서 클래스에 적합한 메서드가 존재하는지 찾아본다. 존재하면 실행하고 탐색을 종료한다.
  2. 못찾았다면, 계속 상속계층의 끝까지 class를 탐색하며 찾아본다. 존재하면 실행하고 탐색을 종료한다.
  3. 자바같은 정적타입언어에서는 컴파일 시점에서 오류가 나서 할 수 없지만, 동적타입언어에서는 상속계층 끝까지 탐색하고 없다면 에러를 배출한다.

상속메서드탐색경로



this vs super

위와 같은 동적 메서드 탐색 경로에서 참조 변수에 대한 처리는 어떻게 될까?
일종의 참조 변수에 해당하는 this,super에 대해서는 아래와 같이 정의할 수 있다.

  • this : 지금 이 클래스부터 메서드 탐색을 시작하세요. 메세지를 수신한 현재 Instance 객체부터 찾고 싶습니다.
  • super : 지금 이 클래스의 부모 클래스부터 메서드 탐색을 시작하세요

this 는 동적으로 생성된 인스턴스의 메서드를 기준으로 탐색을 시작한다.

상속메서드탐색경로2


만약 부모의 메서드에서 this가 나왔다면, 그 this는 위 사진의 this 포인터를 타고 다시 자식부터 탐색한다.
그래서 부모의 this는 자식의 오버라이딩 된 메서드가 실행된다.

이를 통해 this는 동적인 바인딩으로 추후에 어떤 Instance에 의해 호출될지 모르는 미정의 상태이다.
반면, super는 딱 자신의 부모부터 시작이라고 명시하여 구체적인 수행체의 시작점을 선정했다고 볼 수 있다.

부모 자식간의 동일한 메서드 명으로 다른 파라미터를 가지게 만들면 덮어지지 않고 별 개의 메서드로 존재하게 된다.
이 또한 오버로딩이다. 같은 클래스 내부에서만 만드는 것을 오버로딩이라 하지 않는다.




상속에 대한 오해 풀기


상속을 이용해서 공통 타입을 생성하고, 다형성을 처리하기 위해 어떤 메서드를 사용할지 탐색과정을 알아봤다.
상속의 최우선 사용 전략은 타입 계층을 구현하는 용도로 사용되어야 한다.

  • 부모 클래스 - 일반화,추상화
  • 자식 클래스 - 특수화

만일 코드 재사용의 목적으로 상속이 사용된다면 자식과 부모의 강한 결합으로 인해 변경 하기 어려운 코드를 얻게 된다.
타입 계층 구현을 목표로 사용하면 다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있다.

상속의 가치는 동일한 메세지,요청에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해 타입 계층을 구성하는데 쓰여야 한다.



상속의 타입 계층 구현을 통해 서브 타입 다형성동적 메서드 탐색 에 대해 알 수 있게 해준다.
이제 타입 계층은 무엇인지, 상속을 올바르게 사용하 방법으로 다형적 처리에 대해 알아보자.

객체지향에서 타입이란?

객체지향 프로그래밍에서 타입을 정의하다Public Interface를 정의하는 것과 동일하다.
반대로 동일한 메세지를 처리하면 동일한 타입이다. 객체에서 중요하게 보는 것은 속성이 아닌 행동 이다.
결과적으로 객체를 볼 때 중요하게 봐야 하는 것은 외부에 제공하는 행동이다. 타입이 결정되기 때문이다.

타입 계층을 위해 올바르게 사용하는 상속이란?

다음 두 가지 물음에 긍정일 경우 올바르게 상속을 사용한다고 한다.

  1. 상속 관계가 is-a 관계를 모델링 하는가?
    • EX) 사자는 동물이다 가 가능한가?
  2. 클라이언트 입장에서 부모 클래스와 자식 클래스 끼리 행동 호환성을 가지는가?
    • EX) 클라이언트 입장에서 부모와 자식의 차이를 모르는가?



구체적인 예시로 알아보자. 펭귄 is 새 이다.
그럼 펭귄은 새의 자식으로 상속을 받는다. 그런데 우리가 새에게 기대하는 동작은 fly() 날기다.
그러나 펭귄은 날 수 없다. 이처럼 is-a 모델링만 가지고는 타입계층을 위해 상속을 올바르게 사용하는지 알 수 없다.

행동 호환성을 판단하는 것은 지극히 클라이언트 주관이다.
만약 새가 나는 기능이 없는 프로그램이라면 펭귄은 새와 모든 기능이 호환될 수 있다.
그러나 두 타입이 동일하게 행동하지 않는다고 판단되면 두 타입을 타입 계층으로 묶어선 안된다.



Q : 그럼 어떻게 해야 할까? -> 상속 계층을 분리하면 된다.

날 수 있는 새와 날 수 없는 새로 나눈다.

public class Bird{
    
}

public class FlyingBird extends Bird{
    public void fly() {}
}


이렇게 클라이언트 관점에 따라 행동호환성을 고려하여 바꾸지 않는 경우, 몇 가지 부작용적인 코드가 탄생한다.

펭귄이 나는 행동을 하는 보편적인 새의 타입 계층으로 들어가 상속받는 경우,

public class Penguin extends Bird {
    
    @Override
    public void fly () {
        //경우 1. 날지 못하니 아무 동작 하지 않기;
        //경우 2. throw new 못날아Exception();
    }
}

public class Bird {
    
    public void fly (Bird bird){
      //경우 3. if(!(bird instanceof Penguin)) {날기 수행}
    }
}


경우 1과 2는 펭귄에게 불필요한 기능이 상속되어 날지 못하는 펭귄을 위해 날기 기능에 추가 조치를 취한다.
경우 3은 들어오는 객체 타입을 비교하여 펭귄일 경우 날지 않게 구현체를 조건문에 등록한다.

우리는 이전의 공부로 구현체가 외부에 드러나고, 불필요한 기능을 상속하는 행위들이 얼마나 확장과 수정에 악영향을 끼치는지 배웠었다.
이런 경우 위에서 말한 것 처럼 상속 분리를 통해 클라이언트 입장에서 날지 못하는 새와 나는 새로 구분하여 분리하면 된다.



Q : 상속이 아닌 합성을 사용하는 경우는 어떤 해결책이 있을까?

합성으로만들기 합성으로만들기


상속을 통해 만든 사례에서 깨달은 바는 클라이언트의 기대에 맞게 분리할 수록 유연한 설계가 된다는 점이다.
이를 합성으로 만들게 되면, 클라이언트의 기대를 가진 행동 별로 interface를 만들고, 특정 구현체들은 그 기능들을 가져다 구현하면 된다.
그리고 재사용성을 높이기 위해 펭귄은 새를 합성하여 새의 기능을 사용할 수 있다.
Flyer의 Client와 Walker의 Client는 각각 격리되어 서로 변경에 영향을 미치지 않는다.


이와 같이 사용자의 기대되는 행동에 따라 인터페이스로 분리하여 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(ISP) 라고 한다.


중요한 점은 요구사항 속에서 클라이언트가 기대하는 행동에 집중해야 한다. 두 클래스 사이에 행동이 호환되냐 아니냐를 잘 생각해야 한다.
요구사항에 비행이 필요가 없다면 불필요하게 설계를 복잡하게 만들 필요가 없기 때문이다. 라는 단어에 혹하면 안된다.




단순 재사용성을 위한 상속을 클래스 상속이라고 한다.
서브 타이핑은 인터페이스 상속 이라고 한다.

행동 호환성을 가지고 자식이 부모의 모든 기능을 수행할 수 있어 대체 가능하여야 인터페이스 상속이라고 할 수 있다.
이러한 원칙을 리스코프 치환원칙(LSP) 라고 하며 올바른 상속인 인터페이스 상속에 대한 원칙이다.


클라이언트는 사각형에게 가로와 세로가 다를 수 있고 이를 기반으로 행동을 생각한다.
그런데 정사각형은 무조건적으로 가로와 세로가 같아야 한다.
사각형의 자리에 정사각형을 치환할 수 없으므로 여기서 정사각형은 사각형을 상속할 수 없다.
사각형에 대한 클라이언트의 기대와 다른 특징을 가지고 있기 때문이다.

부모에 대한 자식 타입들인 서브타입에 대해 잘 생각해봐야 한다. 자연어가 주는 의미와 헷갈리면 안된다. 사각형과 정사각형의 관계는 LSP 원칙을 어기고 있다.




결론

유연한 설계로 확장하기 쉽고 변경에 튼튼한 설계를 얻기 위해서는 서브타이핑을 잘 해야 한다.
그래야 클라이언트는 추상클래스에 의존할 수 있게되고, 이 추상클래스에는 어떤 구현체나 와서 종류만 다른 같은 역할을 수행할 수 있다. (DIP 원칙)
구현체가 바뀌어도 큰 맥락에서 클라이언트가 기대하는 기능에 훼손이 있지 않다는 점이 핵심이다.
그러기 위해서 우리는 (LSP 원칙)을 알아봤다. 부모의 자리에 각종 자식들이 교체되어도 같은 기대의 역할을 수행한다.
결국 이러한 구현체들은 부모인 추상체와 같은 타입으로 묶이게 된다.
이런 작업을 서브타이핑이라고 한다. 코드의 재사용성이 아닌 유연함을 위한 상속 기법이다.
서브타이핑은 꼭 상속으로 구현해야 하는것은 아니다. 인터페이스를 사용하는 것도 보여줬듯이 다양한 방법이 있고 앞의 2 원칙을 지키면 된다.

서브타이핑의 계약 조건

당연한 말이지만 재사용이 아닌 타입 정의를 위해 상속을 사용한다면, validation 처리를 할 때 몇 가지 원칙이 있다.

  • 서브타입에 더 강한 사전 validation을 걸면 안된다.
    • LSP 원칙을 생각해보면 치환하였을 때 부모의 파라미터 그대로 받아도 작동해야 한다.
  • 서브타입에 슈퍼타입보다 더 약한 validation을 걸면 안된다.
    • 최소한 부모가 필요하다고 한 파라미터의 제약대로 받아야 한다.
  • 서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다.
    • 메서드 수행 결과에 대해서는 유연히 다른 종류의 동작을 수행하기 때문에 결과물에 대해서는 상관 없다.
  • 그러나 슈퍼타입보다 더 약한 사후조건을 걸 순 없다.
    • 클라이언트가 기대하는 동작에 못미치는 결과가 나올 수 있기 때문이다.


우리는 우리가 쓰는 언어와 형성된 선입견으로 타입을 바라보지 말고, 작성하려는 동작과 클라이언트가 역할에 기대하는 행동에 빗대어
같은 행동을 하면 같은 타입이라는 발상의 전환이 필요하다.
그 방법중 하나로 상속을 사용하기도 한다.