Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
JAVA/Java Intermediate(OOP)

8. 상속, 오버라이딩, super

by Toughie 2024. 3. 11.

 

 

객체지향의 4대 특징(캡슐화, 상속, 추상화, 다형성) 중 상속에 대해 Araboza.


추상화

먼저 추상화는 '복잡한 것을 단순하게' 만드는 것을 말한다고 할 수 있다.

그 방법 중 하나는, 복잡한 객체들 사이에서 공통된 특징을 뽑아내는 것이다.

 

세상에는 많은 자동차들이 존재한다. 가솔린차, 전기차, 수소차 등...

근데 분명 자동차라면 응당 존재하는 속성이나 기능이 존재한다.

바퀴, 문, 핸들과 같은 속성이 있을 것이고 문 열기, 엑셀 밟기, 주유하기 등의 기능이 있을 것이다.

 

자동차들을 각각 클래스로 만드는 방법도 있겠지만, 공통된 부분을 뽑아서(추상화해서) '자동차'라는 클래스를 만든다면?

그리고 이 클래스를 상속한 세부적인 자동차들로 만들면 중복된 코드는 줄이고 더 효율적인 프로그래밍이 가능하다.

 

참조변수를 선언할 때도 각 자동차별 타입으로 만드는 대신, 

'자동차' 타입을 사용하면 더 유연하고 의존성이 적은 코드를 작성할 수 있다.

 

오케이.. 복잡한 것을 단순하게, 공통된 부분을 추출하는 개념이 추상화구나. 그것을 구현하기 위해 상속이라는 개념이 존재하는구나 정도 알았다면 상속이 무엇이고 어떻게 쓰는 것인지 더 알아보자.


상속 (extend)

상속은 보통 자식이 부모님의 재산을 상속받는다~와 같은 비유로 많이 설명된다.

즉 부모 클래스를 자식이 상속하면 부모 클래스의 속성과 기능을 그대로 물려받는다는 표현이다.

아래와 같이 extends 키워드를 사용한다. (extends 뒤에 상위, 즉 부모 클래스가 온다.)

 

class Parent {
	int parnetValue;
}

class Child extends Parent {
	int childValue;
}

jav는 단일 상속

자바에서는 단일 상속만 지원한다. 즉 자식 클래스의 부모는 하나만 존재해야 한다.(다중 상속을 지원하는 언어도 있다.)

(법적 관계로 가면 복잡해 지겠지만,, 생물학적 부모는 유일하다고 생각하면 확실히 외울 수 있다.)

즉 extends 뒤에는 단 하나의 클래스만 올 수 있다는 것이다. 

이것으로 인한 답답합은 추후 다룰 '인터페이스'를 통해서 해소할 수 있다. 

인터페이스는 다중 구현이 가능하기 때문이다. 

 

상속을 하면 자식 클래스는 부모 클래스의 멤버와 메서드를 다 물려 받는다고 하는데,

이 표현보다는 영어 표현 그대로 extend, 즉 확장된다는 개념이 오해를 덜 사는 것 같다. (개인적으로)

처음에는 그럼 부모 클래스의 변수나 메서드가 다 복사되는건가? 하는 생각을 하기도 했기 때문이다.

 

상속은 확장이다. 기존의 것도 있고, 새로운 것들이 추가된다는 의미이다. 

이것은 상속과 메모리 구조를 살펴보며 더 자세히 이해해보자.


상속과 메모리 구조

자식 클래스의 객체를 생성하면,

자식 클래스뿐만 아니라 상속 관계에 있는 부모 클래스까지 함께 포함해서 인스턴스를 생성한다.

 

위 문장이 정말 상속 개념에 있어 가장 핵심이라 생각한다.. 

컴파일러에 의해 코드가 삽입되는 건가? 값들이 복사되는 건가? 와 같은 이상한 의문들이 해소되었기 때문이다.

 

public static void main(String[] args) {
	Child child = new Child();
}

 

위 코드를 보면 child는 Child 타입의 포인터다. 즉 힙 영역의 메모리 주소를 가리키는 것이다.

이 힙 영역을 보면, 부모 클래스의 객체, 자식 클래스의 객체가 부모/자식으로 구분되어 함께 생성되어 있다.

참조값(child 변수)은 하나지만 포인팅 해보면 그 안에는 두 클래스의 객체가 동시에 존재하는 것이다!

 

이 원리를 알게 되면, 다형성에서 깊게 다룰 업캐스팅과 다운캐스팅도 더 쉽게 이해할 수 있다.

포인터는 메모리의 시작 주소, 포인터의 타입은 메모리의 시작 주소로부터 타입에 맞게 읽는 것이라 생각한다면 

아래 코드와 같이 업캐스팅(자식 객체의 참조 변수 타입을 부모 타입으로 설정)된 경우 부모의 필드 및 메서드에만 접근 가능한 이유가 와닿을 것이다. 

 

public static void main(String[] args) {
	Parent child = new Child();
}

 

우선, 쉽게 자식 객체를 자식 타입으로 만든 상황부터 생각하자.

기본적으로 참조 변수를 통해 필드에 접근하거나 메서드를 호출하면 '참조 변수의 타입'이 가장 먼저 고려된다.

즉 Child 클래스 객체에 대한 주소를 child 타입의 참조변수로 선언해 두었다면,

우선 Child 객체로 가서 필드를 찾고, 없으면 Parent 객체에 가서 필드를 찾는다는 것이다.

메서드의 경우는 조금 더 복잡한데, 이는 오버라이딩 개념을 통해서 알아보자. 

 

+

부모는 자식을 모르지만, 자식은 부모를 안다.

자식은 자신의 부모가 누구인지 알지만, 부모는 자기의 자식이 누구인지 모른다.

코드만 봐도 자식 클래스에서는 extends 뒤에 부모 클래스를 명시적으로 적어주지만,

부모 클래스 옆에는 자식에 관한 정보가 아무것도 없다. 

실제로 그림으로 상속관계를 표현할 때도 자식 클래스에서 부모 클래스로 향하게 그리는 이유가 이 때문이다.


메서드 오버라이딩 (Method overriding)

쉽게는 부모 타입의 기능을 자식에서 다르게 재정의 해서 사용하는 것을 말한다.

 

자동차 클래스에서 '주유하기'라는 함수가 있었다면,

전기차 클래스에서 해당 함수를 입맛에 맛게 바꿔 '전기 충전하기'로 동작하게 하는 것으로 비유할 수 있겠다.

 

메서드 오버라이딩을 꽤? 깊게 이해하려면 함수 포인터, 가상 함수 테이블, 메서드 디스패치라는 개념이 필요하다.

 

부모 함수에서 정의한 함수를 자식에서 전부 다 가지고 있다고 생각해 보자. 힙 영역에 불필요한 낭비가 일어날 것이다.

그렇기 때문에 함수가 정의되어 있는 메모리의 주소(데이터 영역!)만 가지고

함수를 호출해서 메모리를 효율적으로 사용한다. 

 

자바에서는 메서드 호출을 위해 '가상 함수 테이블'을 사용한다.

해당 테이블에는 메서드에 대한 포인터들이 들어있으며, 클래스 마다 존재한다.

(상속이 일어나면 부모 클래스의 테이블이 자식 테이블에 포함된다고 이해하자.)

 

해당 내용은 설명하자면 너무 길어질 것 같아 결론부터 말하자면 아래와 같다.

 

자식 클래스 객체를 만들면, 힙에는 부모 객체와 자식 객체가 각각 같이 생기고 
각 객체마다 멤버변수는 따로 가지고 있고, 객체마다 가상메서드 테이블을 가지고 있다. 
이 가상 메서드 테이블은 함수포인터의 모음으로, 재정의가 되지 않으면 메소드 영역의 부모 함수를 가리키고,

재정의가 된 경우 메소드 영역의 자식 클래스에 정의된 함수를 가리킨다. 


똑같은 이름의 함수 포인터로 참조변수의 타입에 맞게 함수 호출을 런타임에 적절히 바꿔서 하는 것을 메서드 디스패치,

동적 바인딩이라 하고 이는 다형성의 중요한 개념이다.

 

 

구글링을 하다 보면 위와 같은 메소드 오버라이딩과 상속의 메모리 구조에 대한 자료를 볼 수 있는데,

좀 더 쉬운 이해를 위해서 따로 그림을 그려보았다.

위 그림의 상세 설명은 아래와 같다. (악필과 CamelCase 못 지킨 것은 양해 바랍니다..)

 

1. parent value, basicMethod(), parentmethod()를 가진 부모(Parent) 클래스를 만든다.

 

2. 부모 클래스를 상속한(extends Parent) Child 클래스를 만든다. 자식 클래스에는 child value, child method(),

그리고 부모의 parentmethod()가 오버라이딩 되어 있다.

 

3. 메인 함수에서 Child 타입으로 Child 객체를 생성한다.  (Child instance = new Child();)

-> Child의 생성자에서 Parent의 생성자를 호출하며( super(); ) 부모의 객체가 힙에 생긴다.

그리고 같은 공간에서(옆 방이라 생각하자) Child의 객체가 생긴다. 

 

4. 여기서 instance는 Child 타입의 포인터로, 부모와 자식 객체가 함께 있는 메모리 영역을 가리킨다. 

 

5. 먼저 Heap에 있는 Parent의 인스턴스를 살펴보면,

내부에 Parent 클래스에서 정의해 둔 메서드들에 대한 포인터가 모여있는 가상 메서드 테이블이 존재한다.

이 포인터들은 Method 영역에 있는 Parent 클래스의 함수들을 가리킨다.

( basic method(), parent method() ) 또한 부모 클래스에서 선언한 parent value도 만들어졌다.

 

6. 다음으로 Heap에 있는 Child의 인스턴스를 살펴보면, 

부모 클래스 객체에 있던 가상 메서드 테이블과 더불어 Child 클래스에서 정의한 함수에 대한 포인터가 추가로 존재한다.(child method) 또한 자식 클래스의 멤버인 child value도 존재한다.

 

하지만 여기서 parent method는 자식 클래스에서 '오버라이딩' 되었다. 

가상 테이블에 있는 녀석들은 '함수 포인터'라고 했다. 단순하게 생각하면, 지금 parent method 함수는 두 개가 존재하는데(원본과 오버라이딩), 포인터는 하나인 것이다.

그렇다면? 참조 변수의 타입에 따라서 가리키는 대상을 바꿔주면 되는 것이다. 

컴파일러는 참조 변수의 타입을 기준으로 메서드를 찾는다. 

 

즉 참조 변수의 타입이 자식 클래스라면, 가상 메서드 테이블의 parent method가 자식 클래스에서 재정의된 함수의 메모리 주소를 가리킬 것이고, 참조 변수의 타입이 부모 클래스라면, 가상 메서드 테이블의 parent method가 부모 클래스에서 정의된 함수의 메모리 주소를 가리킨다는 것이다. 

하나의 이름으로 이것저것 다르게 가리킬 수 있지 않은가? 이것을 Method Dispatch 즉 메서드 디스패치라고 하기도 하고, 런타임에 실제 실행될 메서드를 결정하는 프로세스로 인해 동적 바인딩이라는 표현을 쓰기도 한다.

이렇게 다양한 방식으로 함수의 포인팅을 바꿀 수 있기에, 이는 객체 지향의 다형성을 구현하는데 중요한 요소이기도 하다.

 

7. 마지막으로 자식 타입의 객체를 부모 타입의 참조 변수로 받으면? (Parent instance2 = new Child() )

힙 영역에 부모 객체와 자식 객체는 둘 다 생기지만, 포인터(참조 변수)의 타입이 '부모' 타입이기에 Heap 영역에서

1번으로 표시해 둔 부모 객체의 영역에만 접근 가능하다. 여기서 parent method를 호출하면 부모 클래스에서 정의된 함수가 호출되는 것이다. (오버라이딩 된 경우와 비교해 보자.)


@Override

메서드 오버라이딩 시 프로그램이 읽을 수 있는 특별한 주석인 어노테이션을 활용할 수 있다.

 

오버라이딩 조건( 이름 동일, 파라미터 동일, 반환타입 상관없음)을 만족시키지 않으면 컴파일 에러가 발생한다.

java: method does not override or implement a method from a supertype

 

코드의 명확성과 안정성을 위한 추가적인 제약이다.

또한 자식 클래스에서 추가적으로 구현한 메서드인지, 오버라이드 한 건지 더 명확히 표현할 수 있다.

(실수로 파라미터 빼먹으면 오버로딩이 될 수도 있다 ㄷ ㄷ.)


오버로딩과 오버라이딩

오버로딩(Overloading)

이름이 같고, 파라미터(개수, 타입)가 달라야 한다. 리턴타입은 달라도 상관없다. (단 리턴 타입만 다르면 안 됨)

사실 이름만 같다고 보는 게 이해하기 쉽다.(동명이인)

 

오버라이딩(Overriding)

오버라이딩의 조건은 아래와 같다. 

 

- 메서드 이름은 같아야 한다.

- 파라미터의 타입, 순서, 개수 다 같아야 한다.

- 반환 타입 같아야 한다.

- 상위 클래스의 메서드보다 더 제한적인 접근제어자를 사용할 수 없다.

- 상위 클래스의 메서드보다 더 많은 예외를 던질 수 없다.

- static은 클래스 레벨에서 동작하기 때문에 instance 레벨의 오버라이딩이 의미가 없기에 불가능하다.

- final은 말 그대로 재정의를 금지한다는 의미이기에 오버라이딩 불가하다.

- private의 경우 해당 클래스 내부에서만 접근 가능하기에 하위 클래스에서 접근이 불가해 오버라이딩도 불가하다.

 

- 생성자는 오버라이딩 할 수 없다. 또한 상속도 되지 않는다.

자식 클래스의 생성자에서는 항상 부모 클래스의 생성자를 호출해야 한다. 

만약 생성자가 오버라이딩 된다면, 어떤 생성자를 호출해야 하는지 모호하기 때문에 상속이 되지 않는다.

생성자의 역할은 필드를 초기화해주는 것이다. 각 클래스마다 입맛에 맞게 개발자가 초기화를 해주는 것이 

오히려 좋기 때문에 생성자는 상속되지 않는다고 이해하자.


상속과 접근 제어 

UML 표기법

 

private - 외부 호출을 전부 막는다.

default (package-private) 같은 패키지 안에서 호출 허용

protected - 같은 패키지 안 or 패키지가 달라도 상속 관계의 호출은 허용

public - 모든 외부 호출 허용

 

package access.parent;

public class Parent {
    public int publicValue;
    int defaultValue;
    protected int protectedValue;
    private int privateValue;

    public void publicMethod() {
        System.out.println("Parent.publicMethod");
    }

    void defaultMethod() {
        System.out.println("Parent.defaultMethod");
    }

    protected void protectedMethod() {
        System.out.println("Parent.protectedMethod");
    }

    private void privateMethod() {
        System.out.println("Parent.privateMethod");
    }
    public void printParent() {
        publicMethod();
        defaultMethod();
        protectedMethod();
        privateMethod();
    }
}

 

(부모와 자식의 패키지가 다른 상황이다.)

package access.child;

import extends1.access.parent.Parent;
public class child extends Parent {
    public void possibleCall() {
        publicValue = 1; //public은 자유롭게 가능
        protectedValue = 1; //다른 패키지 but 상속 관계
//        defaultValue = 1; //다른 패키지로 접근 불가
//        privateValue = 1; //private access

        publicMethod();
        protectedMethod();
//        defaultMethod();
//        privateMethod(); 

        printParent(); //public
    }
}

 

 

+ 당연하지만

class 앞에 final 키워드를 붙이면 해당 클래스는 상속이 불가능하다.!


super 

부모와 자식의 필드명이 같거나, 메서드가 오버라이딩 되어 있으면 자식에서 부모의 필드나 메서드를 호출할 수 없다.

이때 super 키워드를 사용하면 명확히 부모 클래스를 참조할 수 있다.

 

public class Child extends Parent {
    public String value = "child";

    @Override
    public void hello() {
        System.out.println("Child.hello");
    }

    public void call() {
        System.out.println("child value: " + this.value);
        System.out.println("parent value: " + super.value); //부모 클래스의 value 접근
        hello(); //현재 클래스의 hello 호출
        super.hello(); //부모 클래스의 오버라이딩 되지 않은 hello 호출
    }
}

super 생성자

상속한 자식 인스턴스를 생성하면 메모리에는 자식과 부모 클래스 객체가 전부 만들어진다고 했다.

즉 각각의 생성자가 모두 호출되어야 하기에 자식 클래스의 생성자에서 부모 클래스의 반드시 호출하는 것이 규칙이다. 

 

super는 무조건 첫 줄에!

 

자식 클래스에서 생성자를 여러 개 만들 수 있기에, 생성자 내부에서 this를 통해 다른 생성자를 호출할 수도 있지만

결국은 super 생성자를 호출해줘야 한다. 

여러모로 혼란을 방지하기 위해 부모 생성자를 명시적으로 호출해줘야 하는 경우(기본 생성자가 아닌 경우)

자식 생성자의 첫 줄에 super를 호출해 주자. 

 

아래 코드를 통해 생성자의 호출 순서에 대해 알아보자. 

 

public class ClassA {
    public ClassA() {
        System.out.println("A 생성");
    }
}

public class ClassB extends ClassA {

    public ClassB(int a) {
        super(); // 기본 생성자는 생략 가능 (자바가 만들어줌)
        System.out.println("B 생성 :" + a);
    }

    public ClassB(int a, int b) {
        super(); //기본 생성자 생략 가능
        System.out.println("B 클래스 생성자 a, b: "+ a + b);
    }
}

public class ClassC extends ClassB{

    public ClassC() {
        super(10,20); //b에는 생성자가 직접 정의되어 있음. 하나만 골라!
        System.out.println("C 생성");
    }
}

public class Super2Main {
    public static void main(String[] args) {
        ClassC classC = new ClassC();
    }
}

 

classC의 생성자 호출 -> super를 통해 classB 생성자 호출 -> super를 통해 classA 생성자 호출

A 객체 생성 -> B 객체 생성 -> C 객체 생성 순서로 이루어진다.

부모님이 먼저 태어나야 자식이 태어난다고 생각하자 ㅎㅎ


쓰다보니 꽤 방대해졌지만, 상속에 대한 이해에 많은 도움이 되었다.

이를 바탕으로 이후 다형성에 대해 다뤄볼 예정이다. 

 

 

 

 

학습 출처

김영한의 실전 자바 - 기본편

'JAVA > Java Intermediate(OOP)' 카테고리의 다른 글

11. 추상 클래스와 인터페이스  (0) 2024.03.19
10. 다형성 - 캐스팅과 오버라이딩  (2) 2024.03.17
7. final  (0) 2024.03.07
6. static 정적  (3) 2024.03.07
5. 접근 제어자와 캡슐화  (0) 2024.03.03