[오브젝트] 다형성

풀어갈 나의 이야기·2021년 12월 4일
1

JAVA

목록 보기
7/7

오브젝트 책을 스터디하는 과정에서 정리한 글입니다.

다형성

  • 상속의 목적은 코드 재사용이 아님
    • 타입 계층을 구조화 하기 위해 사용해야 한다.
    • 타입 계층은 객체지향 프로그래밍의 중요한 특성중 하나인 다형성의 기반을 제공한다.
* 피터 코드의 상속 규칙
"규칙에 만족하지 않는다면 상속을 이용하면 안된다"
1 "자식 클래스와 부모 클래스 사이는 역할 수행 관계가 아니어야 한다"
2 "한 클래스의 인스턴스는 다른 서브 클래스의 객체로 변환할 필요가 절대 없어야 한다"
3 "자식 클래스가 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장 수행해야 한다"
4 "자식 클래스가 단지 일부 기능을 재사용할 목적으로 유틸리티 역할을 수행하는 클래스를 상속하지 않아야 한다"
5 "자식 클래스가 역할, 트랜잭션, 디바이스 등을 특수화 해야한다"
  • 상속을 사용하려는 목적이 단순히 코드를 재사용 하기 위함이니?
    • (YES) 라면 상속을 사용하지 말아야 한다.
  • 사용자 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위해서이니?

  • 객체지향이 주목받기 시작하던 초기에 상속은 타입계층과 다형성을 구현할 수 있는 유일한 방법이였음.
    • 상속을 사용하면 코드를 쉽게 재사용 할수 있다는 과대광고가 널리 퍼지면서, 맹신과 추종이 자라나게 됨

11장의 목적

  • 상속의 관점에서 다형성(Polymorphism) 이 구현되는 기술적인 메커니즘을 살펴보자.

다형성이란?

  • 그리스어의 "많은"을 의미하는 'poly'와 "형태"를 의미하는 'morph' 의 합성어로 많은 형태를 가질수 있는 능력을 의미
  • Computer Science 에서의 다형성은, 추상 인터페이스에 대해 코드를 작성하고 서로 다른 구현을 연결할 수 있는 능력을 뜻함

간단히 말해 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법

오버로딩 다형성

  • 일반적인 하나의 클래스 안에 동일한 이름의 메소드가 존재하는 경우
public class Money {
    public Money plus(Money amount) { ... }
    public Money plus(BigDecimal amount) { ... } 
    public Money plus(long amount) { ... }
}

강제 다형성

  • 자동적인 타입 변환이나, 사용자가 직접 구현한 타입변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식
    • 자바의 이항연산자인 '+' 는 피연산자가 모두 정수일 경우 덧셈을 해주고, 다른 문자열이 있을 경우 연결연산자로 동작하는 방식

매개변수 다형성

  • 클래스의 인스턴스 변수나, 메소드의 매개변수 타입을 임의의 타입으로 선언한 후 (제네릭처럼..) 사용하는 시점에 타입을 지정하는 방식
    • List 인터페이스는 컬렉션에 보관할 요소 타입을 로 지정하고 있고, 실제 인스턴스를 생성할 시점에 T를 구체적인 타입으로 지정하게 할수 있음

포함 다형성

  • 메시지가 동일하더라도, 수신한 객체가 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미 (11장의 합성과 연관이 있음)
    • 포함 다형성은 Subtype 다형성이라고도 부름
    • 객체지향 프로그래밍에서 가장 널리 알려진 형태의 다형성 이므로, 특별한 언급이 없으면 포함 다형성을 의미한다.
public static void execute(Calculator calculator) {
    System.out.println("실행결과");
    calculator.run();
}
 
 public static void main(String[] args) {
    Calculator c1 = new CalculatorDecoPlus(); // 다형성
    c1.setOprands(10,20);
    
    Calculator c2 = new CalculatorDecoMinus(); // 다형성
    c2.setOprands(10,20);
    
    execute(c1);
    execute(c2);
}
  • 두 클래스를 상속 관계로 연결하고, 자식 클래스에서 부모 클래스의 메소드를 오버라이딩 한 후 클라이언트는 부모 클래스만 참조하면 포함 다형성을 구현할 수 있음

상속의 양면성

  • 객체지향 패러다임의 근간을 이루는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것.
객체지향 프로그램을 작성하기 위해서는 항상
"데이터" 와 "행동"이라는
두가지 관점을 함께 고려해야함 

데이터 관점의 상속

  • 부모클래스에서 정의한 모든 데이터를 자식클래스의 인스턴스에 자동으로 포함 시킬수 있음

행동 관점의 상속

  • 메소드 역시 자동으로 자식 클래스에 포함 시킬수 있음

상속의 목적은 코드 재사용이 아니다.

  • 상속은 프로그램을 구성하는 개념들을 기반으로, 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것

상속 예제

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

    • pass, fail 은 강의를 이수한 수와 낙제한 수이고, 뒷부분은 등급별로 학생들의 분포현황을 나타냄
  • Lecture.java
    public class Lecture {
        private int pass; // 이수여부
        private String title; // 과목명
        private List<Integer> scores = new ArrayList<>(); // 성적을 보관할 List scores를 인스턴스 변수로 가짐 
    
        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.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 lecture = new Lecture("오브젝트 스터디", 70, Arrays.asList(81,95,75,50,45));
String evaluration = lecture.evaluate();  // 결과 : "Paas:3 Fail:2"

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

  • Lecture 의 출력결과에 등급별로 통계를 추가하는 기능인 클래스가 필요하다고 가정한다. GradeLecture

  • GradeLecture.java
    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() {
            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();
        }
    
        public 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);
        }
    }
  • 등급의 이름과 각 등급 범위를 정의하는 최소성적과 최대성적을 인스턴스 변수로 포함한다.

  • Grade.java
    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;
        }
    }
  • GradeLecture.java
    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(); // Lecture 클래스의 evaluate 메소드를 실행
        }
    
        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();
        }
    
        public 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);
        }
    }
    * 여기서 주목할 부분은 GradeLecture 와 Lecture에 구현된 두 evaluate 메소드의 시그니처가 완전히 동일하다. * 부모와 자식이 동일한 시그니처를 가진 메소드가 존재할 경우, 자식의 우선순위가 높다. (`뒤 후반부에 설명`) * 동일한 시그니처의 경우 자식 메소드가 부모 클래스의 메소드를 가리게 된다. * 부모 클래스의 구현을 새로운 구현으로 대체하는것을 `메소드 오버라이딩` 이라고 한다.
Lecture lecture = new GradeLecture("오브젝트 스터디", 70, Arrays.asList(new Grade("A", 100, 85),
                                                                    new Grade("B", 84, 80)), 
                                                              Arrays.asList(81,95,75,50,45));
lecture.evaluate();  // 결과 : "Paas:3 Fail:2, A:1 B:1..."
  • 부모클래스에서 정의한 메소드와 이름은 동일하지만, 시그니처는 다른 메소드를 자식 클래스에 추가하는 것을 메소드 오버로딩 이라 부름

여기까지 상속을 설명하는데 예제가 준비됨, 데이터와 행동이라는 두가지 관점에서 살펴보기

데이터 관점의 상속

  • GradeLecture 의 인스턴스를 생성했다고 가정하면, Lecture가 정의한 인스턴스 변수도 함께 포함된다.
    • 메모리 상에 생성된 GradeLecture 의 인스턴스는 다음과 같이 표현이 가능하다.
  • p.400

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

행동 관점의 상속

  • 부모 클래스가 정의한 일부 메소드를 자식 클래스의 메소드로 포함시키는 것을 의미
    • evaluate() 를 부모가 구현해두었기 때문에, 자식인 GradeLecture 는 evaluate 를 구현하지 않아도 처리할 수 있었음
  • 어떻게 부모 클래스에서 구현한 메소드를 자식 클래스의 인스턴스에서 수행할수 있을까?
    • 런타임시 자식 클래스에 정의되지 않은 메소드가 있을 경우, 부모 클래스에서 탐색하기 때문임

행동 관점에서 상속과 다형성의 기본 개념을 이해하려면, 상속 관계로 연결된 클래스 사이의 메소드 탐색과정을 이해하는 것이 중요함

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

  • 인스턴스는 두개가 생성되었지만, 단 하나의 메모리에 로드됬다. 각 객체는 자신의 클래스인 Lecture 의 위치를 가리키는 class 라는 이름의 포인터를 가지며
    이 포인터를 이용해 자신의 클래스 정보에 접근할 수 있다.
    또한 Lecture 가 자신의 부모 클래스인 Object 의 위치를 가리키는 parent 라는 이름의 포인터를 가진다.
    이 포인터를 이용하면, 클래스의 상속 계층을 따라 부모 클래스의 정의로 이동하는 것이 가능하다

업캐스팅과 동적 방인딩

같은 메세지, 다른 메소드..

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

  • Professor.java
    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));
                                                
professor.compileStatistics();
// 결과 : [이교수] Paas:3 Fail:2 - Avg:69.2
  • 만약 자식클래스인 GradeLecture 의 인스턴스를 전달하면 ??
    • 생성자의 인자타입은 Lecture 로 선언되 있지만, GradeLecture 의 인스턴스를 전달하더라도 아무 이상없이 실행이 됨.
      • 동일한 객체 참조인 lecture에 대해 evaluate 메시지를 전송하는 동일한 코드 안에서 서로 다른 클래스 안에 구현된 메소드를 실행할 수 있다는 사실임

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

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

업캐스팅

  • 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는것이 가능한걸 업캐스팅이라 함
    • 명시적으로 타입변환 없이도, 부모클래스를 대체할 수 있게 허용해줌
Lecture lecture = new GradeLecture (...) ; // 가능

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

Professor professor = new Professor("이교수", new GradeLecture(...)); // 가능 
  • 반대로 부모 클래스의 인스턴스를 자식 클래스 타입으로 변환하기 위해서는 명시적으로 타입 캐스팅이 필요함
    * 다운 캐스팅이라 함

동적바인딩

  • 전통 언어에서 함수를 실행하는 방법은 함수를 호출하는것.

    • 호출할 함수를 컴파일타임에 결정된다.
    • 코드상에서 bar 함수를 호출하는 구문이 나타나면, 실제로 실행되는 코드는 바로 bar 함수임. 그 외에 어떤 코드도 아님
      • 이는 코드를 작성하는 시점에 호출될 코드가 결정된다는 것을 의미
      • 컴파일타임에 호출할 함수를 결정하는 방식을 정적바인딩, 초기바인딩, 컴파일타임바인딩 이라 부름
  • 객체지향 언어에서는 메시지를 수신했을 때 실행될 메소드가 런타임에 결정

    • foo.bar() 라는 코드를 읽는것만으론 실행되는 bar 가 어떤 클래스의 어떤 메소드인지 알지 모름..
    • 이처럼 실행될 메소드를 런타임에 결정하는 방식을 동적바인딩, 지연바인딩 이라 부름

동적 메소드 탐색과 다형성

  • 객체 지향 시스템은 다음 규칙에 따라 실행할 메소드를 선택함

    • 메시지를 수신한 객체는 자신을 생성한 클래스에 적합한 메소드가 있는지 검사, 이후 실행 > 종료
    • 메소드를 못찾으면, 부모 클래스에서 해당 메소드를 탐색 > 메소드를 찾을때까지 상속계층 상위로 올라감
    • 최상위 Object까지 갔을때도 메소드를 발견하지 못할경우 예외를 발생시키며 탐색을 종료
  • 메시지 탐색과 밀접한 변수는 self 참조 이다.

    • 객체가 메시지를 수신하면, 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정함
    • 아까 설명한 class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메소드를 탐색함

  • 시스템은 메시지를 처리할 메소드를 탐색하기 위해 self 참조가 가리키는 메모리로 이동함.

자동적인 메시지 위임

  • 프로그래머는 메시지 위임과 관련된 코드 (클래스별로 훑고 지나가는거..) 를 명시적으로 작성할 필요가 없다.
  • 자동적인 메시지 위임 : 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 자동으로 상속 계층을 따라 부모 클래스에게 처리를 위임한다.
    • 자동으로 위임된다.
  • 메서드를 탐색하기 위해 동적인 문맥을 사용 : 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지는 런타임에 이뤄지며 self 참조를 이용해서 결정한다.
    (self가 가리키는 인스턴스를 기준으로 탐색을 시작한다.)

동적인 문맥

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

  • 동일한 코드라고 하더라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다.

    • 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.
public class Lecture {
    public String stats() {
        return String.format("Title ....., getEvaluationMethod());
    }
    
    public String getEvaluationMethod() {
        return "Pass or Fail"
    }
}
  • Lecture 클래스에서 stats 메소드 안에 자신의 getEvaluationMethod() 를 호출한다.
    but. 자신의 getEvaluationMethod 를 호출한다고 표현했지만, 사실 정확한 말은 아님
    현재 클래스의 메소드를 호출하라는 것이 아닌, 현재 객체에게 getEvaluationMethod 메시지를 전송하라는 뜻임

    현재 클래스의 메소드를 호출하는 것이 아니라 , 현재 객체에게 메시지를 전송하라는 것.

  • 그럼 현재 객체가 뭔데?.. 바로 self 참조가 가리키고 있는 객체이다.
    • Lecture 의 인스턴스가 stats 메시지를 수신하면, self 참조는 Lecture 인스턴스를 가리키도록 할당됨 (그래서 Lecture 를 호출하는것임)

개념 중요

  • GradeLecture 에 stats을 전송하면 self 참조는 GradeLecture 의 인스턴스를 가리키고 있음. 그래서 메소드 탐색은 GradeLecture 클래스부터 시작하게 된다.
    • GradeLecture 클래스는 stats 메시지를 처리할 적절한 메소드가 없으므로, 부모 클래스인 Lecture 에서 메소드 탐색을 계속하고 실행하게 된다.
    • 여기서 self 참조가 가리키는건 GradeLecture. 하지만 stats 메소드를 실행하는 중에 getEvaluationMethod를 만나게 될땐, self참조가 가리키는 GradeLecture 에서부터 다시 시작한다

super

  • 자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다.
    대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공한다.
public class GradeLecture extends Lecture {
  @Override
  public String evaluate() {
    return super.evaluate() + ", " + gradesStatistics();
  }
}
public class FormattedGradeLecture extends GradeLecture {
  public FormattedGradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
    super(name, pass, grades, scores);
  }
  
  public String formatAverage() {
    return String.format("Avg: %1.1f", super.average());
  }
}

상속대 위임

  • 상속을 이용하면 자식 클래스에서 메시지를 처리하지 못하는 경우 상속 계층에 따라 메시지를 위임한다.
    • 이 경우 self참조는 무엇을 가리키는가?
    • 메시지를 위임하더라도 self는 맨 처음 메시지를 수신한 객체를 가리킨다.

일반적인 상속

public class Grade Extends Evaluation {

     public void push(String subject, int data) {
          add(subject, data);
     }

     public void revise(String subject, int data) {
          change(subject, data);
     }

     public String view(String contents) {
          return contents;
     }
}

위임

  • 클래스를 이용한 상속 관계를 객체 사이의 합성관계로 대체해서 다형성을 구현하는것
  • 상속(Interitance)과 다르게 위임(Delegation)은 다른 클래스의 객체를 멤버로 갖는 형태의 클래스 정의다.
    • 위임은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는, 메소드의 탐색과정을 다른 객체로 이동시키기 위해 사용한다.
      • 이를위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다.
    • GradeLecture 의 stats 메소드는 직접 메시지를 처리하지 않고, Lecture 의 stats 메소드에게 요청을 전달한다.
      • 이를 곧 위임 이라고 부른다.
public class Grade {
     private Evaluation evaluation = new Evaluation();
          public void push(String subject, int data) {
               evaluation.add(subject, data);
          }

          public void revise(String subject, int data) {
               evaluation.change(subject, data);
          }

          public String view(String contents) {
               return evaluation.result(contents);
          }

          public int size() { // 위임 메소드를 서브 클래스에 추가했다
               return evaluation.size();
          }
}

profile
깨끗한 스케치북 일수록 우아한 그림이 그려지법, 읽기 쉽고, 짧은 코드가 더 아름다운 법.. 또한 프로그래머의 개발은 구현할 프로그래밍이 아닌, 풀어갈 이야기로 써내려가는것.

0개의 댓글