Chpater 12. 다형성

Minjae An·2023년 12월 10일

오브젝트

목록 보기
12/15

OOP가 주목받기 시작하던 초기에 상속은 타입 계층과 다형성을 구현할 수 있는 거의 유일한 방법이었다. 하지만 최근의 언어들은 상속 외에도 다형성을 구현할 수 있는 다양한 방법들을 제공하고
있기에 과거에 비해 상속의 중요성이 많이 낮아졌다.

이번 장은 다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을
통해 구현되며, 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층 형태로 구현하기 위한 방법이라는 사실을 안내한다.

🚙 다형성

다형성 이란 단어는 ‘많은 형태를 가질 수 있는 능력’을 의미한다. 컴퓨터 과학에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다.

OOP에서 사용되는 다형성은 아래 그림과 같이 분류할 수 있다.

하나의 클래스 안에 동일 이름의 메서드가 존재하는 경우를 가리켜 오버로딩 다형성이라 부른다.

public class Money {
	public Money plus(Money amount) { ... }
	public Money plus(BigDecimal amount) { ... }
	public Money plus(long amount) { ... }
}

위와 같이 메서드 오버로딩을 활용하면 유사한 작업을 수행하는 메서드의 이름을 통일하여 여러
이름을 기억할 필요가 없다.

강제 다형성은 언어가 지원하는 자동 타입 변환이나 사용자 구현 타입 변환을 통해 동일 연산자를
다양한 타입에 사용할 수 있는 방식을 가리킨다. 자바에서 + 연산자를 정수와 문자열에 사용하였을 때 다른 형태로 작동하는 것이 대표적 예시다. 오버로딩 다형성과 함께 사용하면 실제 호출되는
메서드를 판단하기가 어려워 모호함이 발생한다.

매개변수 다형성제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수, 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체 타입으로 지정하는 방식을 가리킨다.
예시로 자바의 List 인터페이스는 실제 인스턴스 생성 시점에 임의의 타입 T 에 구체 타입을
지정할 수 있게 한다.

포함 다형성은 메시지가 동일하더라도 수신 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. 서브타입 다형성이라고도 불리며, 보편적인 다형성의 의미가 이 유형이다.

포함 다형성을 구현하는 가장 일반적인 방법은 상속을 이용하는 것이다. 상속이 클래스들을
계층으로 쌓아올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다.

🪙 상속의 양면성

OOP의 근간을 이루는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것이다. 따라서 데이터와 행동이라는 두 관점을 함께 고려해야 한다.

상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 포함시킬 수
있다. 이것이 데이터 관점의 상속이다. 데이터뿐 아니라 부모 클래스에서 정의한 일부 메서드 역시
자식 클래스로 포함시킬 수 있다. 이것이 행동 관점의 상속이다. 하지만 이런 재사용 관점은 상속을 오해한 것이다.

상속의 본질적 목적은 프로그램 구성 개념들을 기반으로 다형성을 기능하게 하는 타입 계층을
구축하기 위함이다.

이번 장에서 상속의 메커니즘을 이해하기 위해 필요한 몇 가지 개념들을 알아본다.

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

상속을 사용한 강의평가

Lecture 클래스 살펴보기

수강생들의 성적을 계산하는 간단한 예제 프로그램을 구현해 보자. 아래 형식으로 전체 수강생들의 성적 통계를 출력한다.

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

앞부분은 이수 학생 수, 낙제 학생 수를 나타낸 것이고, 뒤부분은 등급별 학생 분포 현황이다. 먼저
정의된 것으로 가정한 Lecture 클래스는 아래와 같은 형식으로 통계를 출력한다. 이를 기반으로
나머지 기능들을 구현한다.

Pass:3 Fail:2
public class Lecture {
    private int pass;
    private String title;
    private List<Integer> scores = new ArrayList<>();

    public Lecture(int pass, String title, List<Integer> scores) {
        this.pass = pass;
        this.title = title;
        this.scores = scores;
    }

    public double average() {
        return scores.stream()
                .mapToInt(Integer::intValue)
                .average().orElse(0);
    }

    public List<Integer> getScores() {
        return Collections.unmodifiableList(scores);
    }

    public String evaluate() {
        return String.format("Pass:%d Fail:%d", passCount(), failCount());
    }

    private long passCount() {
        return scores.stream().filter(score -> score >= pass).count();
    }

    private long failCount() {
        return scores.size() - passCount();
    }
}

이수 기준이 70점인 과목의 수강생 5명에 대한 성적 통계는 아래와 같이 구할 수 있다.

Lecture lecture = new Lecture(70,
        "객체지향 프로그래밍",
        Arrays.asList(81, 95, 75, 50, 45));

System.out.println(lecture.evaluate()); // 결과 => Pass:3 Fail:2

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

Lecture 출력 결과에 등급별 통계를 추가하는 기능을 제공하는 GradeLecture 를 구현한다. 이는 Lecture 를 상속하여 간단히 구현한다.

public class GradeLecture extends Lecture {
    private List<Grade> grades;

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

Grade 클래스는 등급 이름, 각 등급 범위를 정의하는 데이터를 포함한다.

public class Grade {
    private String name;
    private int upper, lower;

    public 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;
    }
}

이제 GradeLecture 클래스에 evaludate 메서드를 작성한다.

public class GradeLecture extends Lecture {
    private List<Grade> grades;

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

    @Override
    public String evaluate() {
        return super.evaluate() + ", "+gradeStatistics();
    }

    private String gradeStatistics() {
        return grades.stream()
                .map(this::format)
                .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 메서드 시그니처는 GradeLectureLecture 에서 완전히 동일하다. 이때 자식 클래스의 메서드가 더 높은 우선순위를 가지게 되며, 이처럼 자식 클래스에서 상속받은 동일 시그니처의
메서드를 제정의해서 부모의 구현을 새 구현으로 대체하는 것을 메서드 오버라이딩이라고 부른다.

한편, 다음과 같이 등급별 평균 성적을 구하는 average 메서드를 추가할 수 있다.

public class GradeLecture extends Lecture {
		private double average(String gradeName) {
        return grades.stream()
                .filter(each -> each.isName(gradeName))
                .findFirst()
                .map(this::gradeAverage)
                .orElse(0d);
    }

    private double gradeAverage(Grade grade) {
        return getScores().stream()
                .filter(grade::include)
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0);
    }
}

GradeLectureaverage 메서드는 Lecture 에 정의된 average 와 이름은 같지만 시그니처는
다르다. 클라이언트는 두 메서드를 모두 호출할 수 있다. 이처럼 부모 클래스 메서드와 이름은
동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것을 메서드 오버로딩이라고 부른다.

데이터 관점의 상속

다음과 같이 Lecture 인스턴스를 생성하면 메모리 상에 생성된 객체의 모습은 그림과 같다.

Lecture lecture = new Lecture(70,
                "객체지향 프로그래밍",
                Arrays.asList(81, 95, 75, 50, 45));

이번에는 GradeLecture 의 인스턴스를 생성했다고 가정하자. 이 인스턴스에는 부모 클래스에서
정의된 인스턴스 변수도 포함한다.

Lecture lecture = new GradeLecture(70,
                "객체지향 프로그래밍",
                Arrays.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));

메모리상에 생성된 인스턴스는 아래와 같은 형태로 표현할 수 있다.

요약하면 데이터 관점에서 상속은 자식 클래스 인스턴스 안에 부모 클래스의 인스턴스를 포함하는
것으로 볼 수 있다.

행동 관점의 상속

행동 관점의 상속은 부모 클래스가 정의한 일부 메서드가 자식 클래스의 메서드로 포함시키는 것을 의미한다. 어떤 메서드가 포함될 지는 언어에 따라 다른 형태일 수 있지만 공통적으로 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 부모 클래스 인스턴스에게 전송할 수 있는 메시지는 자식에게도 전송할 수 있다. 이 메커니즘은 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드를 부모 클래스 내에서 탐색하기 때문에 가능하다.

객체의 경우 서로 다른 상태를 저장할 수 있게 각 인스턴스별로 독립적인 메모리를 할당받는다.
하지만 메서드의 경우 동일 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만
메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다.

이런 구조에 따라 두 개의 Lecture 인스턴스를 생성했을 때 메모리 상태는 아래와 같이 표현할 수 있다.

위 구조에서 자식 인스턴스가 메시지를 수신할 경우 연결된 부모 포인터를 통해 적절한 메서드가
존재하는지 탐색할 수 있다.

GradeLecture 클래스 인스턴스를 생성했을 때의 메모리 구조도 살펴보자.

이 경우도 parent 포인터를 통해 현재 인스턴스의 클래스에서 최상위 부모 클래스에 이르기까지
모든 부모 클래스에 접근하는 것이 가능하다.

🧗 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

지금까지 작성한 프로그램에 각 교수별로 강의에 대한 성적 통계를 계산하는 기능을 추가해 보자.
통계를 계산하는 책임은 Professor 클래스가 맡도록 하자.

public class Professor {
    private String name;
    private Lecture lecture;

    public Professor(String name, Lecture lecture) {
        this.name = name;
        this.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)));

String statistics = professor.compileStatistics();

한편 ProfessorGradeLecture 의 인스턴스를 전달하더라도 아무 문제 없이 실행될 수 있다.

Professor professor = new Professor("다익스트라",
        new GradeLecture(70,
                "알고리즘",
                Arrays.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)));

String statistics = professor2.compileStatistics();

이처럼 코드에서 선언된 참조 타입과 무관하게 실제 메시지를 수신하는 객체의 타입에 따라
실행되는 메서드가 달라질 수 있는 것은 업캐스팅과 동적 바인딩이라는 메커니즘이 작용하기
때문이다.

부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다. 이를 업캐스팅이라 부른다.

선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가
결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다. 이를 동적 바인딩 이라고 부른다.

동일한 수신자에게 동일 메시지를 전송하는 동일한 코드를 이용해 서로 다른 메서드를 실행할 수
있는 이유는 업캐스팅과 동적 메서드 탐색이라는 기반 메커니즘이 존재하기 때문이다.

위 메커니즘은 코드를 변경하지 않고도 기능을 추가할 수 있게 해주어 OCP라는 목적을 달성할 수
있게 해준다.

업캐스팅

업캐스팅을 이용하면 명시적 타입 변환 없이 부모 클래스 타입의 참조 변수에 자식 클래스의
인스턴스를 대입할 수 있게 허용한다. 또한 부모 클래스 타입으로 선언된 파라미터에 자식 클래스
인스턴스를 전달하는 것도 가능하다.

Lecture lecture = new GradeLecture(...);

public class Professor {
	public Professor(String name, Lecture lecture) { ... }
}

Professor professor = new Professor("다익스트라", new GradeLecture(...));

이런 관점에서 부모 클래스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력할 수 있다. 이런 설계는 유연하며 확장이 용이하다.

동적 바인딩

함수를 호출하는 전통적 언어들은 호출될 함수를 컴파일타임에 결정한다. 이런 방식을 정적 바인딩, 초기 바인딩 또는 컴파일타임 바인딩이라고 부른다.

OOP 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. 실행될 메서드를
런타임에 결정하는 방식에 동적 바인딩 또는 지연 바인딩 이라고 부른다.

동적 바인딩을 이용하면 실행 시점에서야 어떤 클래스 인스턴스의 메서드가 호출되는지 알 수 있다.

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

OOP 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다.
    존재하면 메서드를 실행하고 탐색을 종료한다.
  • 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
  • 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못할 경우 예외를 발생시키며 탐색을 중단한다.

메시지 탐색과 관련해서 self 참조 는 중요한 변수이다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. 동적 메서드 탐색은

self 가 가리키는 객체의 클래스에서 시작해 상속 계층의 역방향으로 이뤄지며 메서드 탐색이
종료되는 순간 이 참조는 자동으로 소멸된다.

self와 this

정적 타입 언어에 속하는 C++, 자바 등에서는 self 참조를 this 라고 부른다. 동적 타입 언어에
속하는 스몰토크, 루비에서는 self 참조를 self 라는 키워드를 사용해 나타낸다. 파이썬에서는
해당 참조의 이름을 임의로 정할 수 있지만 관례적으로 self 를 쓴다.

메시지를 수신한 시점의 GradeLecture 인스턴스의 메모리 상태를 통해 메서드 탐색 과정을
살펴보면 다음과 같다.

메서드 탐색은 자식 클래스에서 부모 클래스 방향으로 진행되기 때문에, 자식 클래스에 선언된
메서드가 더 높은 우선순위를 가지게 된다.

종합해보면 동적 메서드 탐색은 두 가지 원리로 구성된다. 첫번째 원리는 자동적인 메시지 위임이다.자식 클래스는 자신이 이해할 수 없는 메시지는 부모 클래스에 위임한다.

두번째 원리는 메서드를 탐색하기 위해 동적인 문맥을 사용한다는 것이다. 메시지를 수신했을 때
실제로 어떤 메서드를 실행할지 결정하는 것은 실행 시점에 이뤄지며, 메서드 탐색 경로는 self
참조를 이용해 결정한다.

자동적인 메시지 위임

핵심은 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.
상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없음에 주목하라. 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다. 상속 계층을 정의하는
것은 메서드 탐색 경로를 정의하는 것과 동일하다.

메서드 오버라이딩은 자식 클래스의 메서드가 동일 시그니처의 부모 클래스 메서드보다 먼저 탐색되기에 벌어지는 현상이다. 한편, 시그니처가 완전히 동일하지 않은 경우 상속 계층에 걸쳐 공존할 수 있으며 이것이 메서드 오버로딩이다.

메서드 오버라이딩

Lecture 클래스의 인스턴스에 evaluate 메시지를 전송하는 코드를 살펴보자.

Lecture lecture = new Lecture(...);
lecture.evaluate();

인스턴스의 상태를 생략하고 self 와 클래스만을 이용해 간략히 표현하면 다음과 같은 형태다.

Lecture 내에 evaluate 메서드가 존재하기에 탐색은 Lecture 에서 바로 종료된다.

이번에는 GradeLecture 인스턴스에 evaluate 메시지를 전송하는 상황을 살펴보자.

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

동일 시그니처의 메서드가 상속 계층에 존재하는 상황에서 가장 먼저 마주치는 자식 클래스의
메서드가 호출된다. 이런 순서로 인해 자식 클래스 메서드가 부모 클래스 메서드를 감추게 된다.

메서드 오버로딩

GradeLecture lecture = new GradeLecture(...);
lecture.average("A");

위 경우는 응답 가능한 averageGradeLecture 에서 바로 찾을 수 있어 탐색이 시작 클래스에서 종료된다.

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


위 코드는 적절한 시그니처를 가진 average 메서드를 상속 계층을 따라 Lecture 에서 발견하게
된다. 오버로딩을 이용하면 상속 계층에서 동일 이름의 메서드들이 공존할 수 있다.

언어에 따라서는 상속 계층 간 메서드 오버로딩을 지원하지 않기도 한다. C++이 대표적이다.

class Lecture
{
public:
	virtual int average();
	virtual int average(std::string grade);
	virtual int average(std:string grade, int base);
};

이제 GradeLecture 클래스에서 average 를 오버로딩해보자.

class GradeLecture: public Lecture
{
public:
	virtual int average(char grade);
};

이 경우 아래와 같이 부모 클래스 선언된 메서드를 호출하면 에러가 발생한다.

GradeLecture *lecture = new GradeLecture();
lecture->average('A');
lecture->average(); // error
lecture->average("A"); // error

상속 계층에서 동일 이름을 가진 메서드가 공존해서 발생하는 혼란을 방지하기 위해 부모
클래스에서 선언한 이름이 동일한 메서드 전체를 숨겨 클라이언트가 사용하지 못하게 한다. 이를
이름 숨기기라고 부른다. 요점은 동적 메서드 탐색과 관련된 규칙이 언어마다 다를 수 있다는 점이다.

동적인 문맥

중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로
바뀐다는 것이다. 그리고 이 문맥을 결정하는 것은 메시지를 수신한 객체를 가리키는 self 참조다.

self 참조가 동적 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지 예상하기 어렵게
만드는데, 대표적인 경우가 자신에게 다시 메시지를 전송하는 *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 참조 객체에서 시작되는 메서드 탐색 과정을 보면 다음과 같다.

메서드 탐색은 처음에 메서드 탐색을 시작했던 self 참조가 가리키는 바로 그 클래스에서부터 다시 시작된다. 이번에는 GradeLecture 클래스에서 getEvlautionMethod 를 오버라이딩해보자.

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

GradeLecturestats 를 메시지를 전송하면 다음과 같은 메서드 탐색 과정을 거친다.

self 전송은 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다. 결과적으로 계층 중간의 오버라이딩과 만나면 코드가 이해하기 어려워질 수 있다.

이해할 수 없는 메시지

객체가 메시지를 이해할 수 없다면 어떻게 할까?

정적 타입 언어와 이해할 수 없는 메시지

정적 타입 언어에서는 코드를 컴파일할 때 상속 계층내 클래스들이 메시지를 이해할 수 있는지
판단한다. 따라서 계층 전체를 탐색한 후 적절한 메서드를 찾지 못하면 컴파일 에러가 발생한다.

Lecture lecture = new GradeLecture(...);
lecture.unknownMessage(); // compile error

동적 타입 언어와 이해할 수 없는 메시지

정적 타입과의 차이점은 동적 타입 언어엔 컴파일 단계가 존재하지 않아 실제 코드를 실행해보기
전엔 메시지 처리 가능 여부를 판단할 수 없다는 점이다.

메서드를 처리할 수 없다는 사실을 self 참조가 가리키는 현재 객체에게 메시지로 전송하기도
한다.대표적인 동적 타입 언어인 스몰토크에서는 doesNotUnderstand 메시지를 전송하며, 루비의
경우 method_missing 메시지를 전송한다.

한편, 동적 타입 언어에서는 이런 ‘이해할 수 없는 메시지’ 메시지에 응답할 수 있는 메서드를 구현할 수도 있다. 이를 통해 동적 타입 언어는 메시지가 선언된 인터페이스와 메시지의 구현을 분리할 수 있다. 이것은 메시지를 기반으로 협력하는 자율적인 객체라는 OOP의 이상에 좀 더 가깝다. 하지만 이런 특성은 코드를 이해, 수정하기 어렵게 하고 디버깅을 복잡하게 만든다.

정적 타입 언어는 이런 유연성을 없지만 런타임에 오류가 발생할 가능성이 줄여 프로그램이 좀 더
안정적으로 수행될 수 있게 한다.

self 대 super

self 의 동적인 특성과 대비해 언급할 가치가 있는 것이 super 참조다.

대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스에 접근할 수 있도록 super 참조라는
내부 변수를 제공한다.

 public class GradeLecture extends Lecture {
		@Override
    public String evaluate() {
        return super.evaluate() + ", " + gradeStatistics();
    }
}

위 코드에서 evaluate메서드는 super 참조를 이용해 부모 클래스에게 evaluate 메시지를
전송한다. 단순히 부모 클래스의 메서드를 호출한다고 생각할 수 있으나 실제 호출되는 메서드는 더 상위에 위치한 조상의 메서드일 수도 있다.

이해를 위해 FormattedGradeLecture 클래스를 구성해보자.

public class FormattedGradeLecture extends GradeLecture {
    public FormattedGradeLecture(int pass, String name, List<Grade> grades,
                                 List<Integer> scores) {
        super(pass, name, grades, scores);
    }

    public String formatAverage() {
        return String.format("Avg: %1.1f", super.average());
    }
}

위 코드에서 부모인 GradeLecture 에는 average 가 정의돼 있지 않으나 조상인 Lectureaverage 가 정의되어 있어 코드가 정상 동작한다.

super 참조의 정확한 의도는 지금 이 클래스의 부모 클래스부터 메서드 탐색을 시작하세요 다. 이를 통해 super 로 실행하고자 하는 메서드가 반드시 부모에 있지 않아도 되는 유연성을 제공한다.

이처럼 super 참조를 이용해 메시지를 전송하는 것을 super 전송이라고 부른다.

self 전송이 메시지 수신 객체의 클래스에 따라 동적으로 탐색 시작 위치를 결정하는데 비해 super 전송은 항상 메시지를 전송하는 클래스의 부모 클래스에서부터 시작된다.

⚡ 상속 대 위임

위임과 self 참조

메서드 탐색 중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를
공유하는 것으로 봐도 무방하다. self 참조는 항상 메시지를 수신한 객체를 가리키기 때문이다.

루비로 구현한 코드를 통해 좀 더 쉽게 이해해보자.

class Lecture
  def initialize(name, scores)
    @name = name
    @scores = scores
  end

  def stats(this)
    "Name: #{@name}, Evaluation Method: #{this.getEvaluationMethod(this)}"
  end

  def getEvaluationMethod()
    "Pass or Fail"
  end
end

이 코드에서 stats 메서드의 인자로 전달받는 this 에는 self 참조가 보관된다.

getEvaluationMethod 메서드는 this 에 실제 전달되는 객체의 해당 메서드가 실행된다.

lecture = Lecture.new("OOP", [1,2,3])
puts lecture.stats(lecture)

위 코드의 경우 LecturegetEvaluationMethod 가 실행된다.

class GradeLecture
  def initialize(name, canceled, scores)
    @parent = Lecture.new(name, scores)
    @canceled = canceled
  end

  def stats(this)
    @parent.stats(this)
  end

  def getEvaluationMethod()
    "Grade"
  end
end

위 클래스에선 자식 클래스 인스턴스가 부모 클래스 인스턴스에 대한 링크를 포함하는 것으로 상속 관계를 흉내 내고 있다. GradeLecturestats 를 실행하기 위해서는 GradeLecture
인스턴스를 직접 전달해야 한다.

grade_lecture = GradeLecture.new("OOP", false, [1,2,3])
puts grade_lecture.stats(grade_lecture)

위 코드에서 중요한 부분은 네 가지다.

  1. 인스턴스 변수 @parentLecture 의 인스턴스를 생성해서 저장한다. 따라서 자식 인스턴스
    에서 부모 인스턴스로 이동할 수 있는 명시적 링크가 추가된다.
  2. stats 메서드는 추가적 작업 없이 @parent 에게 요청을 그대로 전달한다. 이는 부모
    클래스에서 메서드 탐색을 지속하는 동적 메서드 탐색 과정을 흉내낸 것이다. 자식에서 부모로 문맥을 전달하는 상속 관계를 흉내내기 위해 인자로 받은 this 를 그대로 전달한다는 점을
    주시해라.
  3. GradeLecturegetEvaluationMethod 는 자신만의 방법으로 구현하고 있다. 이제 외부에서
    메서드 오버라이딩과 유사하게 Lecture 의 동일 시그니처 메서드가 감춰진다.
  4. GradeLecturestats 에서는 this 그래도 Lecturestats 에 전달한다. 이는 앞선 self 전송에 의한 동적 메서드 탐색 과정과 완전히 동일하며, GradeLecturegetEvaluationMethod가 실행되게 된다.

위 코드의 getEvaluationMethod 처럼 자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서
처리를 요청하는 것을 위임 이라 부른다.

포워딩과 위임

처리를 요청할 때 self 참조를 전달하지 않는 경우를 포워딩이라 부른다. 반면 self 참조를
전달하는 경우 위임이라고 부른다.

상속은 동적으로 메시지를 탐색하기 위해현재의 실행 문맥을 가지고 있는 self 참조를
전달한다. 그리고 이 객체들 사이에서 메시지를 전달하는 과정은 자동으로 이뤄진다. 따라서
자동적인 메시지 위임 이라고 부르는 것이다.

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

클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을
구현하는 유일한 방법은 객체 간 위임을 이용하는 것이다.

대표적인 프로토타입 기반 언어인 자바스크립트에서는 모든 객체들이 다른 객체를 가리키는 용도로 사용되는 prototype 이라는 이름의 링크를 가진다. 앞서 위임을 직접 구현했던 예제에서 인스턴스 변수 @parent 로 부모 인스턴스를 가리켰던 것과 동일한 개념이다.

인스턴스는 메시지를 수신하면 먼저 메시지를 수신한 객체의 prototype 안에서 적절한 메서드가
존재하는지 검사한다. 존재하지 않는다면 prototype 이 가리키는 객체를 따라 메시지 처리를
자동으로 위임한다.

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

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

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

Lecture 를 이용해서 생성된 모든 객체들은 prototype 객체에 정의된 메서드를 상속받는다. 별
작업이 없으면 prototype 에는 최상위 객체인 Object 가 할당된다.

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

GradeLecture.prototype = new Lecture();

GradeLecture.prototype.constructor = GradeLecture;

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

GradeLeture.prototypeLecture 를 할당한 것에 주목하라. Lecture에 정의된 모든 속성과
함수에 접근할 수 있게 된다. 이제 메시지를 전송하면 prototype 체인상 경로를 통해 객체 사이의
메시지 탐색이 자동으로 이뤄진다.

var grade_lecture = new GradeLecture("OOP", false, [1, 2, 3]);
console.log(grade_lecture.stats());

위 코드를 실행할 경우 아래와 같은 형태로 메시지 위임이 이뤄진다. 클래스 기반 언어와의 차이점은 정직인 클래스 간 관계가 아닌 동적인 객체 사이의 위임을 통해 상속을 구현하고 있다는 것이다.

클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며 상속 없이도 다형성을 구현하는
것이 가능하다.

profile
도전을 성과로

0개의 댓글