본문 바로가기

IT Book Summary/Object: 객체지향설계

Chapter 12 다형성

상속의 목적은 코드 재사용이 아닌 타입 계층을 구조화하기 위해 사용해야 함.

 

상속을 사용하려는 목적이 단순히 코드 재사용을 위해서인가?

클라이언트 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위해서인가?

 

상속의 관점에서 다형성이 구현되는 기술적인 메커니즘

- 다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적을 탐색하는 과정을 통해 구현됨.

- 이런 탐색 경로를 클래스 계층의 형태로 구현하기 위한방법

 


01 다형성(Polymorphism)

 

poly(많은) morph(형태) 를 가질 수 있는 능력

 

정의 -> 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력

: 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법

 

다형성 (Polymorphism)
유니버셜 (Universal)  임시 (Ad Hoc) 

매개변수(Parame)

: 제네릭 프로그래밍과 관련.

클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식.

ex) List<T>

포함(Inclusion)

aka 서브타입 (Subtype)다형성

: 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력.

 

오버로딩(Overloading)

: 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우.

강제(Coercion)

: 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식.

 

상속의 진정한 목적은 코드 재사용이 아닌 다형성을 위한 서브타입 계층을 구축하는 것.

 

객체가 메시지 수신 -> 메시지를 처리할 적절한 메서드를 상속계층 안에서 탐색

-> 런타임 상속계층 아나에서 적절한 메서드를 선택하는 방법

     : 어떤 메시지를 수신했는가? 어떤 클래스의 인스턴스인가? 상속계층이 어떻게 구성되는가?

 

 


02 상속의 양면성

 

 

데이터 관점의 상속 - 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킴

 

행동 관점의 상속 - 데이터뿐만 아니라 부모클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킴.

 

 

상속 매커니즘에 필요한 개념들

  • 업캐스팅
  • 동적 메서드 탐색
  • 동적 바인딩
  • self 참조
  • super 참조

 

상속을 사용한 강의 평가

 

Lecture 클래스 살펴보기

 

수강생들의 성적을 계산, 전체 수강생들의 성적 통계를 출력하는 프로그램을 구현해 보자.

 

강의를 이수한 학생의 수, 낙제한 학생의 수 / 등급별로 학생들의 분포 현황 출력

Pass:3 Fail:2, A:1 B:1 C:1 D:0 F:2

 

 

public class Lecture {
    private int pass;
    private String title;
    private List<Integer> scores = new ArrayList();
    
    public Lecture(String title, int pass, List<Integer> scores) {
        this.title = title;
        this.pass = pass;
        this.scores = scores;
    }
    
    public double average() {
        return scores.stream()
            .mapToInt(Integer::intValue)
            .average().orElse(0);
    }
    
    public List<Integer> getScores() {
        return Collections.unmodifiableLis(scores);
    }
    
    public String evaluate() {
        return scores.stream().filter(score -> score >= pass).count();
    }
    
    private long failCount() {
        return scores.size() - passCount();
    }
}

 

 

Lecture lecture = new Lecture("객체지향 프로그래밍",

                                                    70,

                                                    Array.asList(81, 95, 75, 50, 45));

String evaluation = lecture.evaluate(); // 결과 "Pass:3 Fail:2"

 

 

상속을 이용해 Lecture 클래스 재사용하기

 

GradeLecture 클래스에 Grade 인스턴스들을 리스트로 보관하는 인스턴스 변수 grades 추가

 

public class GradeLecture extends Lecture {
    private List<Grade> grades;
    
    public GradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
        super(name, pass, scores);
        this.grades = grades;
    }
}

public class Grade {
    private String name;
    private int upper, lower;
    
    private Grade(String name, int upper, int lower) {
        this.name = name;
        this.upper = upper;
        this.lower = lower;
    }
    
    public String getName() {
        return name;
    }
    
    public boolean isName(String name) {
        return this.name.equals(name);
    }
    
    public boolean include(int score) {
        return score >= lower && score <= upper;
    }
}

//include 메서드는 수강생의 성적이 등급에 포함되는지 검사
// 학생들의 이수여부와 등급별 통계를 함께 반환하도록 evaluate 메서드 재정의
public class GradeLecture extends Lecture {

    @Override
    public String evaluate() {
	    return super.evaluate()+", "+gradesStatistics();
    }
    
    private String gradesStatistics() {
        return grades.stream()
            .map(grade->format(grade))
            .collect(joining(""));
    }
    
    private String format(Grade grade) {
        return String.format(%s:%d, grade.getName(), gradeCount(grade));
    }
    
    private long gradeCount(Grade grade) {
        return getScores().stream()
            .filter(grade::include)
            .count();
    }
}

 

 

evaluate 메서드 내에서 super 를 이용해 부모클래스 메서드 먼저 실행

 

부모와 자식 시그니처가 동일하다는것 주목

자식클래스의 메서드 우선순위가 더 높음.

 

메서드 오버라이딩

- 자식클래스 안에 상속받은 메서드와 동일한 시그니처의 메서드를 재정해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것

 

Lecture lecture = new GradeLecture("객체지향 프로그래밍",
                    70,
                    Array.asList(new Grade("A", 100, 95),
                                new Grade("B", 94, 80),
                                new Grade("C", 79, 70),
                                new Grade("D", 69, 50),
                                new Grade("F", 49, 0)),
                    Array.asList(81, 95, 75, 50, 45));
// 결과 => "Pass:3 Fail:2, A:1 B:1 C:1 D:1 F:1"
lecture.evaluate();

 

 

부모클래스에 없던 새로운 메서드를 자식클래스에 추가하는 것도 가능.

평균 성적을 구하는 average 메서드를 추가해보자.

 

 

public class GradeLecture extends Lecture {
    public double average(String gradeName) {
        return grades.stream()
                    .filter(each -> each.isName(gradeName))
                    .fildFirst()
                    .map(this::gradeAverage)
                    .orElse(0d);
    }
    
    private double gradeAverage(Grade grade) {
        return getScores().stream()
                    .filter(grade::include)
                    .mapToInt(Integer::intValue)
                    .average()
                    .orElse(0);
    }
}

 

부모클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식클래스에 추가하는 것을 메서드 오버로딩

 

 

데이터 관점의 상속

 

상속을 인스턴스 관점에서 바라볼 때 개념적으로 자식클래스의 인스턴스 안에

부모클래스의 인스턴스가 포함되는 것으로 생각하는것이 유용 

 

따라서 자식 클래스의 인스턴스는 자동으로 부모클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 됨.

 

 

행동 관점의 상속

 

: 부모 클래스가 정의한 일부 메서드를 자식클래스의 메서드로 포함시키는 것을 의미.

 

외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송 가능.

 

런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문에.

 

객체의 경우 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 함.

But  메서드의 경우 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한번만 메모리에 로드하고

각 인스턴스 별로 클래스를 가리키는 포인터를 갖게 하는것이 경제적.

 

인스턴스는 두개가 생성됐지만 클래스는 단 하나만 메모리에 로드됨.

각 객체는 자신의 클래스인 Lecture 의 위치를 가리키는 class 라는 포인터를 가지며 이것을 이용해 자신의 클래스 정보에 접근함.

 

 

객체가 메시지를 수신

-> class 포인터로 연결된 자신의 클래스에서 적절한 메서드 존재하는지 탐색

-> 메서드가 존재하지않으면 parent 포인터를 따라 부모클래스의 메서드를 차례대로 탐색.

 

 


03 업캐스팅과 동적 바인딩

 

 

같은 메시지, 다른 메서드

 

각 교수별로 강의에 대한 성적 통계를 계산하는 기능을 추가하자.

 

// 통계를 계산하는 책임
public class Professor {
    private String name;
    private Lecture lecture;
    
    public Professor(String name, Lecture lecture) {
        this.name = name;
        this.lecture = lecture;
    }
    
    // 통계정보를 생성하기 위해 Lecture의 메서드 호출
    public String compileStatistics() {
        return String.format("[%s] %s - Avg: %.1f", name,
            lecture.evaluate(), lecture.average());
    }
}

 

 

다익스트라 교수가 강의하는 알고리즘 과목의 성적 통계

 

Professor professor = new Professor("다익스트라",
                            new Lecture("알고리즘",
                            70,
                            Arrays.asList(81, 95, 75, 50, 45)));
                            
// 결과 => "[다익스트라] Pass:3 Fail:2 - Avg:69.2"
String statistics = professor.compileStatistics();

 

GradeLecture 인스턴스 전달해도 잘 실행됨.

 

Professor professor = new Professor("다익스트라",
                            new GradeLecture("알고리즘",
                            70,
                            Array.asList(new Grade("A", 100, 95),
                                new Grade("B", 94, 80),
                                new Grade("C", 79, 70),
                                new Grade("D", 69, 50),
                                new Grade("F", 49, 0)),
                            Arrays.asList(81, 95, 75, 50, 45)));
                            
// 결과 => "[다익스트라] Pass:3 Fail:2, A:1 B:1 C:1 D:1 F:1 - Avg:69.2"
String statistics = professor.compileStatistics();

 

코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는것은

- 업캐스팅과 동적 바인딩 메커니즘이 작용하기 때문

 

  • 업캐스팅 - 부모클래스 타입으로 선업된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능
  • 동적 바인딩 - 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정. 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능.

 

업캐스팅

 

컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용.

 

이런 특성을 활용할 수 있는 대표적인 두가지

-> 대입문 (자식클래스 인스턴스 대입 허용)

-> 메서드의 파라미터 타입.

 

// 부모클래스 타입 참조변수에 자식클래스 인스턴스 대입 허용
Lecture lecture = new GradeLecture(...);

// 부모클래스 타입으로 선언된 파라미터에 자식클래스의 인스턴스 전달 가능
public class Professor {
    public Professor(String name, Lecture lecture) {
    ...
    }
}

// Professor는 Lecture를 상속받는 어떤 자식 클래스와도 협력할 수있는 확장가능성을 가짐
Professor professor = new Professor("다익스트라", new GradeLecture(...));

 

반대로 부모 클래스의 인스턴스를 자식클래스 타입으로 변환하기 위해서 필요한 명시적인 타입캐스팅 : 다운캐스팅(downcasting)

 

Lecture lecture = new GradeLecture(...);
GradeLecture gradeLecture = (GradeLecture)lecture;

 

동적 바인딩

 

프로그램 내에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적 매커니즘이 다름.

 

함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일 타임에 결정. 코드를 작성하는 시점에 호출될 코드가 결정

-> 정적 바인딩(static binding), 초기 바인딩(early binding) 또는 컴파일타임 바인딩(compile-time binding)

 

객체지향 언어에서는 메시지를 수신했을때 실행될 메서드가 런타임에서 결정. 객체가 실제로 어떤 인스턴스인지, 메서드가 해당 클래스 상속계층 어디에 위치하는지 알아야함 -> 동적바인딩(dynamic binding), 지연바인딩(late binding)

-> 실행 시점에 어떤 클래스의 인스턴스를 생성해서 전달하는지를 알아야 실제로 실행되는 메서드를 알 수 있음.

 


04 동적 메서드 탐색과 다형성

 

객체지향 시스템이 실행할 메서드를 탐색하는 규칙

  • 메시지를 수신한 객체는 자신을 생성한 클래스에 메서드가 존재하는지 먼저 검사.
  • 메서드를 못찾으면 부모클래스에서 메서드 탐색 계속. 적합한 메서드를 찾을 때까지 상속 계층을 올라가며 탐색.
  • 가장 최상위 클래스에 이르러도 못찾으면 예외 발생시킴.

중요한 변수 : self 참조(self reference) = this

 

class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색.

 

동적 메서드 탐색의 두가지 원리 : 자동적인 메시지 위임 , 동적인 문맥

 

 

자동적인 메시지 위임

 

적절한 메서드를 찾을 때까지 상속계층을 따라 부모클래스로 처리가 자동적으로 위임.

루비의 모듈 module, 스몰토크와 스칼라의 트레이트 trait, 스위프트의 프로토콜 protocol 과 확장 extension

자동적인 메시지 위임방식은 언어에 따라 달라짐.

 

메서드 오버라이딩은 모든 객체지향 언어에서 유사, 메서드 오버로딩은 조금씩 달라짐.

 

메서드 오버라이딩

자식 클래스가 부모 클래스의 메서드를 오버라이딩 하면 자식 클래스에서 부모클래스로 향하는 메서드 탐색 순서때문에 

부모와 자식이 동일한 시그니처를 가진경우, 자식클래스의 메서드가 부모클래스의 메서드를 감추게 된다.

 

메서드 오버로딩

시그니처가 다르지만 동일한 이름의 메서드가 공존하는 경우, 메서드 오버로딩.

자바에서 상속계층 관계에서도 메서드 오버로딩 공존 가능. 

 

 

C++ 에서는 같은 클래스 내에서 메서드 오버로딩은 허용하나 자바와 달리 상속 계층 사이의 메서드 오버로딩은 지원하지 않음.

동일한 이름을 가진 부모클래스의 메서드를 숨겨 호출하지 못하게 함 -> 이름숨기기(name hiding)

따라서 부모클래스에 정의된 모든 메서드를 자식클래스에서 오버로딩 해야함.

 

 

동적인 문맥

 

메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀜.

 

self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀수 있다.

 

public class Lecture {
    public String stats() {
        return String.format("Title: %s, Evaluation Method: %s, title, getEvaluationMethod()");
    }
    
    public String getEvaluationMethod() {
        return "Pass or Fail";
    }
}

 

 

getEvaluationMethod() 하는 구문은 현재 클래스의 메서드를 호출하는 것이 아니라

현재 객체에게 getEvaluationMethod 메시지를 전송하는 것

      ->  self 참조가 가리키는 객체

self 참조가 가리키는 바로 그 객체에서부터 메시지 탐색을 다시 시작한다는 것

 

만약 상속관계가 있다면??

 

public class GradeLecture extends Lecture {
    @Override
    public String getEvaluationMethod() {
        return "Grade";
    }
}

GradeLecture 에 stats 메시지를 전송하자

-> 메시지를 처리할 적절한 메서드가 자신에게 없기에 -> 부모의 메서드 탐색 Lecture 의 stat() 실행

-> getEvaluationMethod() 구문에서 self 참조가 가리키는 객체에서 메시지 탐색

-> GradeLecture 인스턴스에서 getEvaluationMethod 메서드 발견 -> "Grade" 출력하게됨.

 

 

 

이해할 수 없는 메시지

 

정적타입언어인 자바에서는 컴파일 에러 발생. 유연성이 부족하나 좀 더 안정적. 실행시점 오류 가능성 줄임.

 

동적타입언어는 현재 객체에게 메시지를 이해할 수 없다는 메시지 전송. 예외 던짐.

따라서 예외 메시지에 응답하는 메서드를 구현해서 처리하는것도 가능함.

메시지 기반으로 협력하는 자율적인 객체.

이런 유연함과 동적인 특성으로 코드가 복잡해질 수 있다.

 

동적 타입언어의 이러한 특징을 이용해 도메인-특화 언어(Domain Specific Language)를 개발하는 방식

-> 동적 리셉션(Dynamic reception)

 

self 대 super

 

자식클래스에서 부모클래스의 구현을 재사용해야하는 경우.

부모클래스의 인스턴스 변수나 메서드에 접근하기 위해 super 참조라는 내부 변수를 제공

 

super 참조를 이용해 부모 클래스에게 메시지를 전송

-> 해당 클래스의 부모클래스에서부터 메서드 탐색하라

 

super 전송의 경우 컴파일 시점에 탐색할 클래스를 미리 결정


05 상속 대 위임

 

self 참조가 동적인 문맥을 결정한다는 것을 이해하면 상속을 새롭게 바라보게 된다

-> 자식클래스에서 부모클래스로 self 참조를 전달하는 메커니즘

 

위임과 self 참조

 

상속 계층을 구성하는 객체들 사이에서 self 참조를 공유하기 때문에 

개념적으로 각 인스턴스에서 self 참조를 공유하는 self 라는 변수를 포함하는 것처럼 표현할 수 있다.

 

self 참조가 공유되는 과정을 구현한 루비코드 분석

1. GradeLecture 인스턴스에서 Lecture 인스턴스로 이동할 수 있는 명시적 링크 추가.

 이 링크를 통해 컴파일러가 제공하던 동적 메서드 탐색 구현.

2. 동적 메서드 탐색은 런타임에 클래스 메타정보를 이용해 자동으로 처리를 위임하나, 

부모클래스의 퍼블릭 메서드를 그대로 선언하고 요청을 전달. 상속관계를 위해 인자로 받은 this 전달.

3.부모클래스의 메서드와 동일한 메서드를 다른방식으로 구현. 오버라이딩.

4. 인자로 전달된 this 에게 메시지 전송 == self 전송에 의한 동적메서드 탐색

 

자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청 -> 위임 delegation

처리를 요청할 떄 self 참조를 전달하지 않는 경우 -> 포워딩 forwarding

 

self 참조의 전달은 자식클래스의 인스턴스와 부모클래스의 인스턴스 사이에 동일한 실행 문맥을 공유하게 한다.

 

프로토타입 기반의 객체지향 언어

 

오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은

객체사이의 위임을 이용하는 것.

 

자바스크립트에서 인스턴스는 메시지를 수신하면

-> 먼저 메시지를 수신한 객체의 prototype 안에서 메시지에 응답할 적절한 메서드가 존재하는지 검사

-> 적절한 메서드가 존재하지 않으면 가리키는 객체에 따라 메시지 처리 자동적으로 위임

 

prototype 체인으로 연결된 객체 사이에 메시지를 위임함으로써 상속을 구현.

 

 

function Lecture(name, scores) {
    this.name = name;
    this.scores = scores;
}

Lecture.prototype.stats = function() {
    return "Name: "+ this.name +", Evaluation Method: "+ this.EvaluationMethod();
}

Lecture.prototype.getEvaluationMethod = function() {
    return "Pass of Fail"
}

// GradeLecture가 Lecture를 상속받게 하고 메서드 오버라이딩
function GradeLecture(name, canceled, scores) {
    Lecture.call(this, name, scores);
    this.canceled = canceled;
}

// Lecture 인스턴스 할당
GradeLecture.prototype = new Lecture();

GradeLecture.prototype.constructor = GradeLecture;

GradeLecture.prototype.getEvaluationMethod = function() {
    return "Grade"
}

 

동적인 객체 사이의 위임을 통해 상속을 구현

 

프로토타입으로 연결된 객체의 체인으로 거슬러 자동적으로 메시지에 대한 위임 처리

 

var grade_lecture = new GradeLecture("OOP", false, [1,2,3]);
grade_lecture.stats();
//Lecture의 stats 메서드 실행

 

객체지향은 객체를 지향하는 것. 클래스 없이도 객체들 사이의 메시지 위임만을 이용해 다형성을 구현

클래스는 객체를 편리하게 사용하려는 요소일 뿐 중요한것은 메시지와 협력

 

클래스 기반의 상속 - 객체 기반의 위임 은 기본개념과 매커니즘을 공유한다.