본문 바로가기
스터디/오브젝트

오브젝트 12 - 다형성

by 디토20 2024. 4. 2.
반응형

 

 

 

 

 

 

 

 

 

오브젝트 12 - 다형성

 

 

12.1 다형성

  • 다형성이란 많은 형태를 가질 수 있는 능력을 의미한다.
  • 컴퓨터 과학에서는 다형성을 하나의 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다.
  • 즉, 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이다.
  • 객체 지향 프로그래밍에서 다형성은 아래와 같이 분류할 수 있다.

 

1. 오버로딩 다형성

 

  • 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리켜 오버로딩 다형성이라고 한다.
  • 메서드 오버로딩을 사용하면 하나의 이름만 기억하면 된다.
  • 이는 유사한 작업을 수행하는 메서드의 이름을 통일할 수 있어 기억해야 하는 이름의 수를 극적으로 줄일 수 있다.
public class Money {
    public Money plus(Money amount) { ... }
    public Money plus(BigDecimal amount) { ... }
    public Money plus(long amount) { ... }
}

 

 

2. 강제 다형성

 

  • 강제 다형성은 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식을 말한다.
  • 예를들어 정수 '+' 정수 일 경우는 정수에 대한 덧셈 연산자로 동작하지만, 정수 '+' 문자열 일 경우는 연결 연산자로 동작한다. 이때 정수형 피연산자는 문자열 타입으로 강제 형변환된다.
  • 일반적으로 오버로딩 다형성과 강제 다형성을 함께 사용하면 실제로 어떤 메서드가 호출될지를 판단하기 어려워 모호해진다.

 

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

 

포함 다형성
  • 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력
  • 객체지향 프로그래밍에서 가장 널리 알려진 형태의 다형성으로, 특별한 언급 없이 다형성이라고 할 때는 포함 다형성을 의미
  • 포함 다형성을 구현하는 가장 일반적인 방법은 상속을 사용하는 것
  • 상속의 진정한 목적은 코드 재사용이 아닌 다형성을 위한 서브타입 계층을 구축하는 것이다.

 

 

12.2 상속의 양면성

  • 상속의 목적은 코드 재사용이 아닌, 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하는 것이다.
  • 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 될 수 있다.
  • 상속을 사용하는 간단한 예제 하나를 살펴보자

 

12.2.1 상속을 사용한 강의 평가

  • 수강생들의 성적을 계산하는 간단한 예제 프로그램을 구현해 보자. 아래와 같은 형식으로 전체 수강생들의 성적 통계를 출력한다.
Pass:3 Fail:2, A:1 B:1 C:1 D:0 F:2

 

  • 해당 형식으로 통계를 출력하는 Lecture 클래스는 아래와 같다.
public class Lecture {
    private int pass;
    private String title;
    private List<Integer> scores = new Arraylist<>();
    
    public double average() {
    	return scores.stream().mapTolnt(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(); 
    }
}

 

  • 추가하기 원하는 기능은 Lecture의 출력 결과에 등급별 통계를 추가하는 것이다.
  • GradeLecture 클래스를 생성하고 grades 변수를 추가하자.
  • 그 후 evaluate 메서드를 재정의 한다
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;
    }
    
    @Override
    public String evaluate() {
    	return super.evaluate() + ", " + gradesStatistics();
    }
    
    private String gradesStatistics() {...}
    
    public double average(String gradeName) {...}

}

 

  • 부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 높다.
  • 자식 클래스가 메시지를 수신했을 때 부모 클래스의 메서드가 아닌 자식 클래스의 메서드가 실행된다.
  • 이처럼 자식 클래스에서 부모 클래스의 메서드를 재정의하는 것을 메서드 오버라이딩이라고 부른다.
  • 자식 클래스의 average 메서드는 부모 클래스의 average 메서드와 이름은 같지만 시그니처가 다르다. 이 경우 두 메서드는 공존할 수 있으며 이를 메서드 오버로딩이라고 한다.

 

12.2.2 데이터 관점의 상속

  • 다음과 같이 Lecture의 인스턴스를 생성했다고 가정하자
Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50, 45));

 

 

  • 인스턴스를 생성하면 시스템은 인스턴스 변수 title, pass, scores를 저장할 수 있는 메모리 공간을 할당한다.
  • 생성된 인스턴스의 주소를 lecture라는 이름의 변수에 대입한다.
  • 아래는 메모리 상에 생성된 객체의 모습을 개념적으로 표현한 것이다.

생성된 인스턴스의 메모리 상태

 

  • 이번에는 GradeLeture의 인스턴스를 생성했다고 가정하자.
  • GradeLecture의 인스턴스는 직접 정의한 인스턴스 변수 뿐만 아니라 부모 클래스인 Lecture가 정의한 인스턴스 변수도 포함한다.
  • 인스턴스를 참조하는 lecture는 GradeLecture의 인스턴스를 가리키기 때문에, 특별한 방법을 사용하지 않으면 부모 인스턴스에 직접 접근할수 없는데, 이는 두가지 방법으로 그려볼 수 있다.

부모 클래스의 인스턴스를 포함하는 자식 클래스
인스턴스 간의 링크로 표현된 상속 관계

 

  • 요약하면 데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있다.
  • 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 된다.

 

 

12.2.3 행동 관점의 상속

  • 데이터 관점의 상속이 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 개념이라면, 행동 관점 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다.
  • 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다.
  • 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다.
  • 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 해당 메서드는 부모 클래스에서 탐색된다.
  • 객체의 경우 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 한다.
  • 반면 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 한다.

 

  • 메시지를 수신한 객체는 class 포인터로 연결된 자신의 클래스에서 메서드를 찾는다.
  • 메서드가 존재하지 않는다면 parent 포인터를 따라 부모 클래스를 차례대로 훑어가면서 적절한 메서드가 존재하는지 검색한다.

GradeLecture 인스턴스의 메모리 구조

 

 

12.3 업캐스팅과 동적 바인딩

12.3.1 같은 메시지, 다른 메서드

  • 코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수시나는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅동적 바인딩 때문이다.
    • 업캐스팅 : 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것
    • 동적 바인딩 : 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정. 객체지향 시스템이 메시지를 처리할 적절한 메서드를 런타입 시점에 결정하기 때문에 가능

 

12.3.2 업캐스팅

  • 상속을 이용하면 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다.
  • 이런 특성을 활용할 수 잇는 대표적인 두가지가 대입문메서드의 파라미터 타입이다.
  • 모든 객체지향 언어는 명시적으로 타입을 변환하지 않고도 부모 클래스 타입의 참조 변수에 자식 클래스의 인스턴스를 대입할 수 있다.
Lecture lecture = new GradeLecture ( ... );

public class Professor {
public Professor (String name, Lecture lecture ) { ... }
Professor professor = new Professor("다익스트라", new GradeLecture(...));

 

 

  • 반대로 부모 클래스의 인스턴스를 자식 클래스 타입으로 변환하기 위해서는 명시적인 타입 캐스팅이 필요하다. 이를 다운캐스팅이라고 부른다.
Lecture lecture = new Gradelecture(...);
GradeLecture GradeLecture = (GradeLecture) lecture;

 

 

12.3.3 동적 바인딩

  • 정적 바인딩 : 함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일타임에 결정한다.
  • 동적 바인딩 : 객체지향 언어에서는 메시지를 수신했을 때 실핼될 메서드가 런타임에 결정된다.
    • foo.bar()라는 코드를 읽는 것 만으로는 실행되는 barrk 어떤 클래스의 어떤 메서드인지 판단하기 어려움.
    • foo가 가리키는 객체가 실제로 어떤 클래스의 인스턴스인지, bar 메서드가 상속 계층의 어디에 위치하는지 알아야 함.

 

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

  • 객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.
    • 메시지를 수신한 객체는 우선 자신의 클래스에 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
    • 메서드가 없다면 부모 클래스에서 메서드 탐색을 계속한다. 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
    • 상속 계층의 가장 최상위 클래스에서도 메서드가 없을 경우 예외를 발생시키며 탐색을 종료한다.
  • 메시지 탐색에는 self 참조라는 중요한 변수가 존재한다.
  • 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다.
  • 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다.
  • 시스템은 앞서 말한 class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색한다.

  • 시스템은 메시지를 처리할 메서드를 탐색하기 위해 self 참조가 가리키는 메모리로 이동한다.
  • 이 메모리에는 객체의 현재 상태를 표현하는 데이터와 객체의 클래스를 가리키는 class 포인터가 존재한다.
  • class 포인터를 따라 이동하면 메모리에 로드된 GradeLecture 클래스의 정보를 읽을 수 있다.
  • 클래스 정보 안에는 클래스 안에 구현된 전체 메서드의 목록이 포함되어 있다.
  • 이 목록안에 메시지를 처리할 적절한 메서드가 존재하면 해당 메서드를 실행 후 동적 메서드 탐색을 종료한다.
  • 존재하지 않으면 상위 클래스로 올라가며 메서드 탐색을 진행한다.

 

 

12.4.1 자동적인 메시지 위임

  • 상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없다.
  • 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다.
  • 상속 계층을 정하는 것은 메서드 탐색 경로를 정의하는 것과 같다.
  • 메서드 오버라이딩은 자식 클래스의 메서드가 동일한 시그니처를 가진 부모 클래스의 메서드보다 먼저 탐색되기 때문에 벌어지는 현상이다.

 

메서드 오버라이딩

 

  • 부모클래스와 자식 클래스는 같은 메서드를 정의하고 있다.
  • 동적 메서드 탐색은 self 참조가 가리키는 객체의 클래스에서 시작되고, 그 안에 메서드가 구현돼있기 때문에 먼저 발견된 메서드가 실행된다.
  • 따라서 자식 클래스의 메서드가 부모 클래스의 메서드를 감추는 것처럼 보이게 된다.
  • 이와 같이 자식 클래스가 메서드를 오버라이딩하면 자식 클래스에서 부모 클래스로 향하는 메서드 탐색 순서 때문에 자식 클래스의 메서드가 부모 클래스의 메서드를 감추게 된다.

 

 

메서드 오버로딩

 

  • 메서드 이름이 동일하지만 시그니처가 다른 경우 두 메서드는 공존할 수 있다.
  • 메서드 오버라이딩은 메서드를 감추지만 메서드 오버로딩은 사이좋게 공존한다.
  • 일부의 언어에서는 상속 계층 사이의 메서드 오버로딩을 지원하지 않는다.

 

12.4.2 동적인 문맥

  • 동일한 코드라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다.
  • self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.
  • self 참조가 가리키는 자기 자신에게 메시지를 전송하는 것을 self 전송이라고 부른다.
  • self 전송은 self 참조가 가리키는 그 객체에서부터 메시지 탐색을 다시 시작한다.

 

 

 

12.5 이해할 수 없는 메시지

  • 객체가 메시지를 이해할 수 없다면 어떻게 할까?
  • 이해할 수 없는 메시지를 처리하는 방법은 프로그래밍 언어가 정적 타입 언어인지, 동적 타입 언어인지에 따라 달라진다.

 

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

  • 정적 타입 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다.
  • 상속 계층 전체를 탐색한 후에도 메시지를 처리할 수 있는 메서드를 발견하지 못했다면 컴파일 에러가 발생한다.

 

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

  • 동적 타입 언어 역시 메시지를 수신한 객체의 클래스부터 부모 클래스의 방향으로 메서드를 탐색한다.
  • 차이점이라면 동적 타입 언어에는 컴파일 단계가 없기 때문에 실제로 코드를 실행하기 전에는 메시지 처리 가능 여부를 판단할 수 없다.

 

12.5.3 self 대 super

  • self 참조의 가장 큰 특징은 동적이라는 점이다.
  • self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다.
  • 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해서는 super 참조라는 내부 변수를 사용한다.
  • super 참조는 부모 클래스일수도 있고, 더 상위의 조상 클래스일 수도 있다.
  • super 전송은 항상 해당 클래스의 부모 클래스에서부터 메서드 탐색을 시작하므로 컴파일 시점에 미리 결정해 놓을 수 있다.

 

 

 

 

 

 

728x90
반응형

댓글