자바 상속의 특징

상속이란 하나의 객체가 부모 객체의 properties와 behaviors를 얻는 것을 의미합니다.

  • 다중상속을 지원하지 않는다.
  • 계층구조의 최상위에 java.lang.Object 클래스가 있다.

상속을 사용하는 큰 이유중 한가지는 코드의 재사용성입니다. 이 뿐만 아니라 Method Overriding을 통해서 runtime polymorphism이 가능해집니다.


클래스 상속

상속의 첫 번째 종류인 클래스 상속은 extends 키워드를 사용해 부모 클래스의 method와 field를 상속할 수 있습니다.

서브 클래스는 슈퍼 클래스의 non-static protectedpublic member만 상속합니다. 그리고 default(package-private) member는 만약 두 클래스가 동일한 패키지에 있는 경우라면 상속합니다.

하지만 privatestatic member에 대해서는 상속하지 않습니다. 그렇다면 아래 코드는 어떻게 작동할까요?

class Base {
    static String str="hi";
}
class Derived extends Base {
}
public class Main {
    public static void main(String args[ ])  {
        Derived obj1 = new Derived();
        System.out.println(obj1.str);
    }
}

static member에 대해서는 상속을 진행하지 않아 출력이 되지 않을 것으로 예상했지만 정상적으로 hi가 출력됩니다.

이러한 경우 상속을 하는 것이 아니라 부모클래스와 자식클래스가 static variable을 “공유"하는 개념으로 이해하면 좋습니다. static 메서드도 살펴볼까요?

class Base {
    public static void display() {
        System.out.println("Static or class method from Base");
    }
}
class Derived extends Base {
}

public class Main {
    public static void main(String args[ ])  {
        Base obj1 = new Derived();
        obj1.display();
    }
}

이 경우에도 static or class method from Base가 출력이 됩니다. 하지만 앞에서 언급했듯이, 상속을 사용하는 이유는 Method Overriding을 활용한 runtime polymorphism라고 했습니다. 이 코드는 Method Overriding이 불가능한걸까요? 아래에서 메서드를 추가해봅시다.

class Base {
    public static void display() {
        System.out.println("Static or class method from Base");
    }
}
class Derived extends Base {
    public static void display() {
        System.out.println("Static or class method from Derived");
    }
}

public class Main {
    public static void main(String args[ ])  {
        Base obj1 = new Derived();
        obj1.display();
    }
}

실행해보면 ... from Base가 출력됩니다. runtime에서 type은 Derived 클래스여서 ... from Derived가 출력될 것으로 기대했지만 예상과 달리 Method overriding이 되지 않았습니다.

이는 static의 특성상 컴파일 타임에 실행될 메서드를 정의하기 때문입니다. 컴파일러는 Base obj1의 정보만을 알 수 있기 때문에 Base 클래스의 메서드가 실행됩니다. 만일 Base obj1이 아닌 Derived obj1으로 수정하여 실행하면 ... from Derived가 출력됩니다.


추상 클래스

자바에서는 하나 이상의 추상 메서드를 포함하는 클래스를 가리켜 추상 클래스라고 합니다. 추상 메서드를 가지고 있다는 점에서 인터페이스와 유사합니다. 그렇다면 인터페이스와 구분해서 언제 사용하는 것이 적합할까요?

  • 연관된 서브 클래스들이 사용할 공통적인 기능을 한 곳에 모아놓기 위해서 사용
  • protected modifier가 있는 메서드나 필드를 서브 클래스들이 상속할 필요가 있는 경우

추상 클래스 또한 base type과 sub type을 다루기 때문에 상속이 갖는 polymorphism의 이점을 얻을 수 있습니다. 요약하자면 abstract를 통해 code reuse가 가능해집니다. 아래는 abstract를 사용하기 적합한 예시입니다.

public abstract class BaseFileReader {

    protected Path filePath;

    protected BaseFileReader(Path filePath) {
        this.filePath = filePath;
    }

    public Path getFilePath() {
        return filePath;
    }

    public List<String> readFile() throws IOException {
        return Files.lines(filePath)
          .map(this::mapFileLine).collect(Collectors.toList());
    }

    protected abstract String mapFileLine(String line);
}

BaseFileReader은 서브 클래스에서 사용할 공통적인 기능, 예를 들어 filePath를 저장하는 기능이나 Path로부터 파일을 읽어 들이는 readFile 메서드를 정의했습니다. 이를 상속하는 서브 클래스에서는 어떻게 파일을 처리할지만 구현을 함으로써 다양한 버전의 서브 클래스를 만들 수 있습니다.

public class LowercaseFileReader extends BaseFileReader {

    public LowercaseFileReader(Path filePath) {
        super(filePath);
    }

    @Override
    public String mapFileLine(String line) {
        return line.toLowerCase();
    }
}
public class UppercaseFileReader extends BaseFileReader {

    public UppercaseFileReader(Path filePath) {
        super(filePath);
    }

    @Override
    public String mapFileLine(String line) {
        return line.toUpperCase();
    }
}

하지만 java 8부터 인터페이스에 default method를 도입함으로써 인터페이스 또한 구현체를 가질 수 있게 되었습니다. 그렇다면 이를 통해 완전히 abstract class를 대체할 수 있을까요? 그렇지는 않습니다. Oracle에 따르면 default method의 목적을 다음과 같이 서술하고 있습니다.

Default methods enable you to add new functionality to existing interfaces and ensure binary compatibility with code written for older versions of those interfaces.

backward compatibility를 제공하면서 추가적인 기능을 제공하기 위해 만들어진 것입니다. 자세한 내용은 다음 글에서 인터페이스에 대해 살펴보며 비교해보도록 하겠습니다.


super 키워드

super은 부모 클래스의 객체를 참조할 때 사용하는 키워드입니다. 만약 서브 클래스의 인스턴스를 만든다면 부모 클래스의 인스턴스는 내재적으로 생성됩니다. 하지만 이는 두 개의 다른 인스턴스가 생성된다는 의미는 아닙니다. 아래 코드를 실행하면 동일한 hashCode가 출력됨을 알 수 있습니다.

class Base {
    public Base(){
        System.out.println(hashCode());
    }
}
class Derived extends Base {
    public Derived(){
        System.out.println(hashCode());
    }
}

public class Main {
    public static void main(String args[ ])  {
        Derived obj1 = new Derived();
    }
}

Derived 클래스 객체를 생성한다면 memory allocator가 자식객체와 부모객체를 위해 메모리를 할당하고 해당 메모리주소를 리턴하게 됩니다.

Derived var = new Derived();

var에는 Heap을 가리키는 레퍼런스가 저장됩니다. 컴파일러가 해당 레퍼런스를 활용하기 위해 Derived를 통해 적절한 정보를 가져옵니다. 만일 타입이 Base라고 선언되어있으면 컴파일러는 객체가 Derived 일지라도 Base portion에만 접근합니다. 이를 그림으로 나타내면 다음과 같습니다.

image

이제 super에 대해 본격적으로 알아봅시다.

super을 사용하면 자식 클래스에 의해 가려진 메서드 또는 필드에 접근할 수 있습니다.

class Base {
    public void print(){
        System.out.println("this is base");
    }
}
class Derived extends Base {
    public void print(){
        System.out.println("this is derived");
    }
    public void display(){
        print();
        super.print();
    }
}

public class Main {
    public static void main(String args[ ])  {
        Derived obj1 = new Derived();
        obj1.display();
    }
}

display() 내부에서 print()를 사용하면 overriding 되어 Derived의 print()만 출력하지만 super keyword를 사용해서 부모 클래스의 print()메서드 또한 사용가능합니다.

또한 super()을 통해 부모 클래스의 생성자에도 접근할 수 있습니다. 만약 자식 클래스의 생성자에 super()가 없다면 컴파일러가 자동으로 자식 클래스 생성자의 첫 줄에 super()을 호출합니다.

class Base {
    public Base(){
        System.out.println("Base constructor");
    }
}
class Derived extends Base {
    public Derived(){
    }
}

public class Main {
    public static void main(String args[ ])  {
        Derived obj1 = new Derived();
    }
}
> result
Base constructor

virtual method table

위 예제처럼 자바에서 상속받은 메서드와 오버라이딩 된 메서드는 어떻게 구분을 하는 걸까요? 이에 대한 정답은 코드를 살펴보면 알 수 있습니다.

Car carInstance = new Car();
car.run();

car.run()을 풀어서 살펴보면 참조변수 + . + 메서드명으로 이루어져 있습니다.

참조변수는 객체에 대한 주소를 담고 있습니다. 즉, 메서드에 대한 정보를 얻기 위해 객체의 주소에 접근하는 코드입니다. 하지만 이전 글에서 살펴보았듯이, 자바는 인스턴스와 클래스에 대한 정보를 아래와 같이 나누어 저장합니다.

  • Class object, including method code and static fields : Method area
  • Object, including instance fields: heap
  • Local variables and calls to methods: stack

메서드 정보에 접근하기 위해서 왜 Heap에 접근을 하는 것일까요? 그 이유는 대부분의 컴파일러가 dynamic dispatch를 구현하기 위해 vtable을 가리키는 pointer를 클래스에 추가하기 때문입니다. pointer를 따라 vtable로 이동하면 모든 virtual method implementation 메모리 주소를 갖고 있습니다.

JVM specification에 따르면 virtual method call을 구현하는 방법을 규정하고 있지는 않습니다. 예를 들어 HotSpot JVM에서는 아래와 같이 작동합니다.

class Foo {
    void foo() {
        System.out.println("foo");
    }
}
class Bar extends Foo{
    void bar() {
        System.out.println("bar");
    }
}
     ------------------      ------------------
    | Foo.class vtable |    | Bar.class vtable |
    |------------------|    |------------------|
    | clone            |    | clone            | \
    | equals           |    | equals           | | java.lang.Object
    | hashCode         |    | hashCode         | / virtual methods
    | ...              |    | ...              |
    | foo              |    | foo              | } Foo virtual methods
     ------------------     | bar              | } Bar virtual methods
                             ------------------

본론으로 돌아와서 레퍼런스로 Heap에 접근하게 되면 아래와 같은 정보를 얻게 됩니다.

객체 안에는 vtable의 주소인 vptr이 저장되어있고 vtable은 클래스가 메모리를 할당받을 때, 클래스 자신의 메소드를 비롯해 상속받은 모든 메서드의 주소를 모아서 생성합니다. 만일 오버라이딩한 메서드가 있다면 자신의 vtable에 부모 메서드가 아닌 메서드영역에 새로 생성된 오버라이딩된 메서드의 주소를 저장하기 때문에 자식 객체가 부모타입으로 선언되더라도 오버라이딩된 메서드를 사용하게 됩니다.

hi

Child1에서 method()를 호출하는 경우 자신의 vtable을 참조를 확인하면 부모

dynamic dispatch

dynamic dispatch에 대한 정의를 찾아보면 다음과 같습니다.

Single dispatch is a way to choose the implementation of a method based on the receiver runtime type

receiver의 runtime type에 따라 메서드의 구현을 선택하는 방법이라고 되어있는데, receiver은 p.post()라는 코드에서 .post()라는 메서드 호출을 p받아서 처리하므로 p가 receiver라고 할 수 있습니다. 아래 예시를 살펴보겠습니다.

image

> result
0.01
0.1

receiver(빨간색)의 runtime type에 따라 method(파란색)를 결정하는 것을 확인할 수 있습니다.

그렇다면 double dispatch란 무엇일까요?

Double dispatch determines the method to invoke at runtime based both on the receiver type and the argument types.

receiver의 타입뿐만 아니라 argument types 또한 살펴보는 방법이 double dispatch입니다. 위 예제를 변형해서 Order class 대신 Order interface를 사용하고 이를 구현한 두 개의 PlainOrderSpecialOrder 클래스가 있다고 가정하겠습니다.

image

이 소스코드에서 39번째줄의 order는 올바른 메서드를 찾아 정상적으로 실행이 될까요? 이 소스코드는 컴파일에러가 발생하게 됩니다.

dynamic dispatch는 파라미터를 기준으로 하는 method dispatch가 아니기 때문입니다. 파라미터를 기준으로 어떤 메서드를 사용할지 결정하는것은 method overloading입니다. 이는 static dispatch, 즉 컴파일 타임에 어떤 메서드를 사용할지 결정하게 됩니다.

컴파일 타임에 주어진 정보는 Order 인터페이스이므로 dynamic dispatch가 불가능해 컴파일이 불가능한것입니다. 이를 통해 자바는 single dispatch 언어임을 알 수 있습니다.

이러한 문제를 해결하기 위해 파라미터가 메서드를 추측하는 대신 또 다른 receiver가 dispatch를 함으로써 double dispatch를 구현할 수 있습니다.

image

파라미터에 따라 호출될 메서드를 정하는 대신(노란색) 해당 파라미터의 메서드를 호출함으로써 파라미터를 또 다른 receiver로 만들었습니다. 즉, single dispatch(빨간색)을 두 번 사용함으로써 double dispatch 효과를 얻게 되었습니다.

장점

Order을 상속한 새로운 클래스를 만들더라도 Policy의 소스코드를 수정하지 않더라도 기능을 추가할 수 있게 됩니다. SRP를 위반하지 않으면서 확장가능한 형태가 완성된 것입니다.


final 키워드

Object 클래스

스터디에서 알게된 내용

java에서 인스턴스 메서드는 파라미터의 첫번째 변수로 this를 추가합니다. 아래 예시에서 aload_0this를 가리키고 aload_1은 Object를 로드합니다.

dd

image


공식문서에서 언급하면 추상클래스 vs 인터페이스

추상 클래스를 선택하면 좋을 때

  • 밀접하게 연관된 클래스에서 공유하고 싶을 때
  • 많은 메서드, 필드가 중복될 때
  • public 이외의 다른 접근지정자(private)가 필요할 때
  • fields를 선언하고 싶을 때
  • 인터페이스를 선택하면 좋을 때

인터페이스를 선택하면 좋을 때

  • 크게 관련이 없는 class들이 사용하는 경우
  • 특정 method가 필요한데 그 구현에 대해 관심이 없을 때
  • 다중 상속이 필요할 때

Reference