본문 바로가기
Back-End/백기선님의 자바 스터디

6주차 - 상속

by 7533ymh 2022. 3. 31.

목표

자바의 상속에 대해 학습하세요.

학습할 것 (필수)

  • 자바 상속의 특징
  • super 키워드
  • 메소드 오버라이딩
  • 다이나믹 메소드 디스패치 (Dynamic Method Dispatch)
  • 추상 클래스
  • final 키워드
  • Object 클래스

자바 상속의 특징

상속의 정의와 장점

기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것

상속을 통해서 클래스를 작성하면 적은 양의 코드로 새로운 클래스를 작성할 수 있고 코드를 공통적으로 관리할 수 있기 때문에 코드의 추가 및 변경이 매우 용이하다.

이러한 특징은 코드의 재사용성을 높이고 코드의 중복을 제거하여 프로그램의 생산성과 유지보수에 크게 기여된다.

자바에서의 상속은 extends 키워드를 통해 구현할 수 있다.

class Parent {}
class Child extends Parent {}

Notes_220330_233224

이 두 클래스는 서로 상속 관계에 있다고 하며, 상속해주는 클래스(위에서는 Parent)를 조상클래스라고 하고 상속 받는 클래스(위에서는 Child)라고 한다.

조상 클래스 - 부모(parent)클래스, 상위(super)클래스, 기반(base)클래스
자손 클래스 - 자식(child)클래스, 하위(sub)클래스, 파생된(derived)클래스

class Parent {
    int age;
}
class Child extends Parent {}

Notes_220331_000901

만약 Parent에 age라는 정수형 변수를 멤버변수로 추가하게 된다면, 자손 클래스인 Child는 조상의 멤버를 모두 상속받기 때문에 자동적으로 age라는 멤버변수가 추가된 것과 같은 효과를 얻을 수 있다.

class Parent {
    int age;
}
class Child extends Parent {
    void play(){
        System.out.println("놀자");
    }
}

Notes_220331_001038

반대로 자손클래스인 Child에 새로운 멤버로 play()메서드를 추가해도 조상인 Parent클래스는 아무런 영향을 받지 않는다. 즉, 조상 클래스가 변경되면 자손 클래스는 자동적으로 영향을 받게 되지만, 자손 클래스가 변경되는 것은 조상 클래스에 아무런 영향을 주지 못한다는 것이다.

  • 생성자와 초기화 블럭은 상속되지 않는다. 멤버만 상속된다.
    • 접근 제어자가 private 또는 default인 멤버들은 상속은 받지만 자손 클래스로부터 접근이 제한되는 것이다.
  • 자손 클래스의 멤버 개수는 조상 클래스보다 항상 같거나 많다.

조상 클래스만 변경해도 모든 자손클래스, 자손의 자손 클래스에까지 영향을 미치기 때문에, 클래스간의 상속관계를 맺어 주면 자손 클래스들의 공통적인 부분은 조상 클래스에서 관리하고 자손 클래스는 자신에 정의된 멤버들만 관리하면 되므로 각 클래스의 코드가 적어져서 관리가 쉬워지게 된다.

public class Tv {
    boolean power;
    int channel;

    void power() {
        power = !power;
    }

    void channelUp() {
        ++channel;
    }

    void channelDown() {
        --channel;
    }
}

public class CaptionTv extends Tv {
    boolean caption;

    void displayCaption(String text) {
        if (caption) {
            System.out.println(text);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        CaptionTv ctv = new CaptionTv();
        ctv.channel = 15;   // 조상 클래스로부터 상속받은 멤버
        ctv.channelUp();    // 조상 클래스로부터 상속받은 멤버
        System.out.println(ctv.channel); // 16

        ctv.caption = true;
        ctv.displayCaption("World");  // word 
    }
}

Notes_220331_001559

Tv클래스로부터 상속받고 기능이 추가된 CaptionTv클래스가 있다. 위 코드를 보면 CaptionTv는 조상클래스인 Tv로 부터 상속받은 channelcahannelUp()을 사용할 수 있다는 것을 알 수 있다.

즉, 자손 클래스의 인스턴스를 생성하면 조상 클래스의 멤버도 함께 생성되기 때문에 조상 클래스의 인스턴스를 생성하지 않고도 조상 클래스의 멤버들을 사용할 수 있는 것이다.

자손 클래스의 인스턴스를 생성하면 조상 클래스의 멤버와 자손 클래스의 멤버가 합쳐진 하나의 인스턴스로 생성된다.

클래스간의 관계 결정하기

상속관계 : "~은 ~이다." / is - a 관계

포함관계 : "~은 ~을 가지고 있다." / has - a 관계

단일 상속

class TVCR extends TV, VCR {...} // 에러!! 조상은 하나만 허용

자바에서는 오직 단일 상속만을 허용한다. 그래서 둘 이상의 클래스로부터 상속을 받을 수 없다.

다중 상속을 허용하면 여러 클래스로부터 상속받을 수 있기 때문에 복합적인 기능을 가진 클래스를 쉽게 작성할 수 있다는 장점이 있지만, 클래스간의 관계가 매우 복잡해진다는 것과 서로 다른 클래스로부터 상속받은 멤버간의 이름이 같은 경우 구별할 수 있는 방법이 없다는 단점때문에 자바는 단일 상속만을 허용한다.

불편한 점도 있지만, 클래스 간의 관계가 보다 명확해지고 코드를 더욱 신뢰할 수 있게 만들어준다.

다형성

여러 가지 형태를 가질 수 있는 능력 - 객체지향개념

한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함 - 자바개념

Tv t = new CaptionTv(); // 조상 타입의 참조변수로 자손 인스턴스를 참조

다형성을 좀 더 구체적으로 말하자면, 조상클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조할 수 있다는 것이다.

그렇다면 두 개의 차이는 뭘까?

Tv t = new CaptionTv();
CaptionTv c = new CaptionTv();

ㅁㄴㅇㅁㅁㄴㅇ

인스턴스를 같은 타입의 참조변수로 참조하는 것과 조상타입의 참조변수로 참조하는 것은 무슨 차이가 있을까?

이 경우 실제 인스턴스가 CaptionTv타입이라 할지라도, 참조 변수t 의 타입이 Tv일 경우 CaptionTv인스턴스의 모든 멤버를 사용할 수 없다. 타입이 Tv인 참조변수 t는 CaptionTv인스턴스 중에서 Tv클래스의 멤버들(상속받은 멤버 포함)만 사용할 수 있다. 따라서, CaptionTv인스턴스 중에서 Tv클래스에 정의 되지 않은 멤버 즉, caption, displayCaption()은 사용할 수 없는 것이다.

둘 다 같은 타입의 인스턴스지만 참조변수의 타입에 따라 사용할 수 있는 멤버의 개수가 달라진다.

그럼 반대로는 가능할까?

CaptionTv c = new TV(); // 컴파일 에러

답은 불가능하다. 실제 인스턴스인 Tv의 멤버 개수보다 참조변수 c가 사용할 수 있는 멤버 개수가 더 많기 때문에 허용하지 않는다.

참조변수가 사용할 수 있는 멤버의 개수는 인스턴스의 멤버 개수보다 같거나 적어야 한다.

참조변수의 형변환

자손 타입 -> 조상타입 (Up - casting) : 형변환 생략가능

자손타입 <- 조상타입(Down - casting) : 형변환 생략불가

public class Main {
    public static void main(String[] args) {
        Tv t = new CaptionTv();      // 업캐스팅
        CaptionTv c = (CaptionTv)t; // 다운캐스팅

        t.displayCaption("world");     // 컴파일 에러

        System.out.println(c.channel);
        c.caption = true;
        c.displayCaption("world");
    }
}

형변환은 참조변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것은 아니기 때문에 참조변수의 형변환은 인스턴스에 아무런 영향을 미치지 않는다. 단지 참조변수의 형변환을 통해서, 참조하고 있는 인스턴스에서 사용할 수 있는 멤버의 범위(개수)를 조절하는 것뿐이다.

public class Main {
    public static void main(String[] args) {
        Tv t = new Tv();
        CaptionTv c = (CaptionTv)t;  // 에러 발생 지점

        c.displayCaption("world");
    }
}

해당 코드는 컴파일은 성공하지만, 실행 시 ClassCastException이 발생한다. 이유는 형변환에 오류가 있기 때문이다. 캐스트 연산자를 이용해서 조상타입의 참조변수를 자손타입의 참조변수로 형변환한 것이기 때문에 문제가 없어보이지만, 문제는 참조변수t가 참조하고 있는 인스턴스가 Tv타입의 인스턴스라는데 있다. 앞써 배운 것처럼 조상타입의 인스턴스를 자손타입의 참조변수로 참조하는 것은 허용되지 않는다.

컴파일 시에는 참조변수간의 타입만 체크하기 때문에 실행 시 생성될 인스턴스의 타입에 대해서는 알지 못한다. 그래서 컴파일시에는 문제가 없었지만, 실행 시에는 에러가 발생하게 되는 것이다.

서로 상속관계에 있는 타입간의 형변환은 양방향으로 자유롭게 수행될 수 있으나, 참조변수가 가르키는 인스턴스의 자손타입으로 형변환은 허용되지 않는다.

그래서 참조변수가 가리키는 인스턴스의 타입이 무엇인지 확인하는 것이 중요하다.

참조변수와 인스턴스의 연결

public class Parent {
    int x = 10;

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

public class Child extends Parent {
    int x = 20;

    @Override
    void print() {
        System.out.println("Child Method");

    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Child();
        Child c = new Child();

        System.out.println("p.x = " + p.x);   // p.x = 10
        p.print();                            // Child Method

        System.out.println("c.x = " + c.x);   // c.x = 20
        c.print();                            // Child Method
    }
}

조상 클래스에 선언된 멤버변수와 같은 이름의 인스턴스변수를 자손 클래스에 중복으로 정의했을 때, 조상타입의 참조변수로 자손 인스턴스를 참조하는 경우와 자손타입의 참조변수로 자손 인스턴스를 참조하는 경우는 서로 다른 결과를 얻는다.

메서드의 경우 조상 클래스의 메서드를 자손의 클래스에서 오버라이딩한 경우에도 참조 변숟의 타입에 관계없이 항상 실제 인스턴스의 메서드(오버라이딩된 메서드)가 호출되지만, 멤버변수의 경우 참조변수의 타입에 따라 달라진다.

super 키워드

super

자손 클래스에서 조상 클래스로부터 상속받은 멤버를 참조하는 데 사용되는 참조 변수

public class Parent {
    int x = 10;

    void print() {
        System.out.println("x = " + x);
    }
}

public class Child extends Parent {
    int x = 20;

    @Override
    void print() {
        System.out.println(x);  // 20
        System.out.println(this.x); // 20
        System.out.println(super.x); // 10
        super.print(); // x = 10
    }
}

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

우리는 멤버변수와 지역변수의 이름이 같을 때 this를 붙여서 구별했다. 그것과 마찬가지로 상속받은 멤버와 자신의 멤버와 이름이 같을 때 super를 붙여서 구별할 수 있다. 즉, 조상의 멤버와 자신의 멤버를 구별하는 데 사용된다는 점을 제외하고는 super와 this는 근본적으로 같다.

모든 인스턴스메서드에는 자신이 속한 인스턴스의 주소가 지역변수로 저장되는 데, 이것이 참조변수인 this와 super의 값이 된다. 하지만 static메서드(클래스 메서드)는 인스턴스와 관련이 없기 때문에 this와 마찬가지로 super역시 사용할 수 없고 인스턴스메서드에만 사용이 가능하다.

super()

조상 클래스의 생성자

class Person {
    public Person() {
        System.out.println("사람");
    }
}

class Student extends Person {
    public Student() {
        // super(); 
        // super()가 생략되어 있다. 컴파일러가 자동으로 추가해준다.
        System.out.println("학생");
    }
}

public static void main(String[] args) {
    Student student = new Student(); // 사람 학생 
}

앞써 배웠던 this()와 마찬가지로 super() 생성자이며 조상 클래스의 생성자를 호출하는데 사용된다.

앞써 우리는 자손 클래스의 인스턴스를 생성하면, 자손의 멤버와 조상의 멤버가 모두 합쳐진 하나의 인스턴스가 생성된다는 점을 배웠다. 이 때 조상 클래스의 멤버의 초기화 작업이 먼저 수행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다. (먼저 수행되지 않는다면 자손 클래스의 멤버가 조상 클래스의 멤버를 참조하면 문제가 된다.)

이와 같은 조상 클래스 생성자의 호출은 클래스의 상소관계를 거슬러 올라가면서 계속 반복되 마지막으로 모든 클래스의 최고 조상인 Object클래스의 생성자인 Object()까지 가서야 끝이 난다. 그래서 Object클래스를 제외한 모든 클래스의 생성자는 첫 줄에 반드시 자신의 다른 생성자 또는 조상의 생성자를 호출해야 한다. 그렇지 않으면 컴파일러는 생성자의 첫 줄에 super()를 자동으로 추가할 것이다.

Object클래스를 제외한 모든 클래스의 생성자 첫 줄에 생성자, this() 또는 super()를 호출해야 한다. 그렇지 않으면 컴파일러가 자동으로 super();를 생성자의 첫 줄에 삽입한다.

class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

class Point3D extends Point {
    int z;

    Point3D(int x, int y, int z) {   // 컴파일 에러
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

class PointMain{
    public static void main(String[] args) {
        Point3D p3 = new Point3D(1,2,3);
    }
}

해당 코드는 컴파일 에러가 발생하게 된다. Point3D클래스의 생성자의 첫 줄이 생성자를 호출하는 문장이 아니기 때문에 컴파일러는 super();자동으로 추가해준다. 하지만 Point클래스에는 Point()라는 생성자가 정의되어 있지 않기 때문에 발생하게 된다. 해결하기 위해선 super(x, y)를 추가해주면 된다.

즉, 조상 클래스의 멤버변수는 조상의 생성자에 의해 초기화되도록 해줘야 한다.

오버라이딩

오버라이딩이란?

조상 클래스로부터 상속받은 메서드의 내용을 변경하는 것

class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    String getLocation() {
        return "x :" + x + ", y :" + y;
    }
}

class Point3D extends Point {
    int z;

    Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    String getLocation() {
        return "x :" + x + ", y :" + y + ", z :" + z;
        //return super.getLocation() + ", z :" + z;
    }
}

상속받은 메서드를 자손 클래스 자신에 맞게 변경할 때 조상의 메서드를 오버라이딩한다.

오버라이딩의 조건

자손 클래스에서 오버라이딩하는 메서드는 조상 클래스의 메서드와

  • 이름이 같아야 한다.
  • 매개변수가 같아야 한다.
  • 반환타입이 같아야 한다.
    • JDK1.5부터 공변 반환타입이 추가되어 부모클래스의 반환타입을 자식클래스의 반환타입으로 변경은 가능하다.
  • 접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경 할 수 없다.
    • 오버라이딩되는 메서드가 조상 클래스에서 protected라면, 자손 클래스에서는 protectedpublic이어야 한다.
  • 조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없다.
  • 인스턴스메서드와 static메서드를 서로 변경할 수 없다.

다이나믹 메소드 디스패치 (Dynamic Method Dispatch)

메소드 디스패치

메소드 디스패치는 어떤 메소드를 호출할지 결정하여 실행시키는 과정을 말한다. 이 과정은 static(정적)과 dynamic(동적)이 있다.

정적 메소드 디스패치(Static Method Dispatch)

컴파일 시점에, 컴파일러가 특정 메소드를 호출할 것이라고 명확하게 알고있는 경우이다.

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

public class Child extends Parent {
    @Override
    void run() {
        System.out.println("Child.run()");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Parent();
        Child c = new Child();

        p.run();  // Parent.run()
        c.run();  // Child.run()
    }
}

위의 코드는 모두 컴파일 시점에 어떤 메소드를 실행해야할 지가 결정이 끝나고 그대로 실행된다.

동적 메소드 디스패치(Dynamic Method Dispatch)

정적 디스패치와 반대로 컴파일러가 어떤 메소드를 호출하는지 모르는 경우이다. 동적 디스패치는 호출할 메서드를 런타임 시점에서 결정한다.

abstract class Parent {
    abstract void run();
}


public class Child1 extends Parent {
    @Override
    void run() {
        System.out.println("Child1.run()");
    }
}

public class Child2 extends Parent {
    @Override
    void run() {
        System.out.println("Child2.run()");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p1 = new Child1();
        p1.run();  // Child1.run()

        p1 = new Child2();
        p1.run();  // Child2.run()
    }
}

위 코드에서 컴파일시에는 p1과 p2의 run이 어떤 인스턴스의 메소드인지 모른다. 앞써 우리는 "컴파일시에는 실행 시 생성될 인스턴스의 타입에 대해서는 알지 못한다."라고 배웠다. 즉, p1과 p2 모두 컴파일시에는 그저 Parent의 run()을 호출하면 되겠구나 하고 있는 것이다. 하지만 런타임으로 들어가게 되면 인스턴스의 타입을 통해 어떤 메소드를 실행해야 하는 지가 결정된다.

Double Dispatch

추상 클래스

abstract - 추상의, 미완성의

abstract가 사용 될 수 있는 곳은 클래스와 메서드 뿐이다.

abstract는 미완성의 의미를 가지고 있다. 메서드의 선언부만 작성하고 실제 로직은 구현하지 않은 추상 메서드를 선언하는 데 사용된다.

그리고 클래스에도 사용되어 클래스 내에 추상메서드가 존재한다는 것을 쉽게 알 수 있도록 한다.

  • abstract 클래스
    • 클래스 내에 추상 메서드가 선언되어 있음을 의미
  • abstract 메서드
    • 선언부만 작성하고 구현부는 작성하지 않은 추상 메서드임을 의미

추상클래스 (abstract class)

추상메서드를 포함하고 있는 클래스 (추상메서드가 없어도 abstract를 붙여 추상클래스로 지정할 수도 있다.)

abstract class 클래스이름 {
    ...
}

추상클래스는 abstract키워드를 사용하며 추상클래스로 인스턴스는 생성할 수 없고 상속을 통해서 자손클래스에 의해서만 완성될 수 있다.

추상메서드를 포함하고 있다는 것을 제외하고는 일반클래스와 전혀 다르지 않다. 생성자가 있으며, 멤버변수와 메소드도 가질 수 있다.

추상메서드 (abstract method)

선언부만 작성하고 구현부는 작성하지 않은 채로 남겨 둔 메서드

abstract 리턴타입 메서드이름();

추상메서드의 내용이 상속받는 클래스에 따라 달라질 수 잇기 때문에 조상 클래스에서는 선언부만 을 작성하고, 실제 내용은 상속받는 클래스에서 구현하도록 한다.

추상클래스로부터 상속받는 자손클래스는 오버라이딩을 통해 조상인 추상클래스의 추상메서드를 모두 구현해주어야 한다. 만약 조상으로부터 상속받은 추상메서드 중 하나라도 구현하지 않는다면, 자손클래스 역시 추상클래스로 지정해주어야 한다.

왜 사용할까?

abstract class Unit {
    int x;
    int y;

    abstract void move(int x, int y);

    void stop() {
        //현재 위치에 정지
    }
}

class Marin extends Unit {
    @Override
    void move(int x, int y) {
        // Marin의 이동방식으로 이동
    }
}

class Tank extends Unit {
    @Override
    void move(int x, int y) {
        // Tank의 이동방식으로 이동
    }
}

class Dropship extends Unit {
    @Override
    void move(int x, int y) {
        // Dropship의 이동방식으로 이동
    }
}

유명한 컴퓨터 게임에 비유하여 설명하겠다. 위 코드에서는 각 클래스의 공통부분을 뽑아서 Unit클래스로 정의하고 상속받도록 하였다.

여기서 Marin,Tank는 지상유닛이고 Dropship은 공중유닛이기 때문에 이동하는 방법이 서로 달라서 move메서드의 실제 구현 내용은 달라질 것이다.

즉, 추상메서드로 선언된 것은 각 클래스에 알맞게 반드시 구현해야 한다는 강제성을 부여하는 것이다.

final 키워드

마지막의 또는 변경될 수 없는

final class FinalTest {          
    final int MAX_SIZE = 10;

    final int getMaxsize() {
        final int LV = MAX_SIZE;
        return MAX_SIZE;
    }
}

final은 거의 모든 대상에 사용될 수 있다.

  • (멤버, 지역)변수
    • 값을 변경할 수 없는 상수
  • 메서드 - 변경될 수 없는 메서드
    • 오버라이딩을 통해 재정의 될 수 없다.
  • 클래스 - 변경될 수 없는 클래스
    • 확장될 수 없는 클래스 즉, 다른 클래스의 조상이 될 수 없다.(상속 불가능!)

생성자를 이용한 final멤버 변수의 초기화

class Card {
    static int width = 100;
    static int height = 200;

    final int NUMBER;
    final String KIND;

    public Card(int NUMBER, String KIND) {
        this.NUMBER = NUMBER;
        this.KIND = KIND;
    }
}

final이 붙은 변수는 상수이므로 일반적으로 선언과 초기화를 동시에 하지만, 인스턴스변수의 경우 생성자에서 초기화 되도록 할 수 있다.

생성자를 통해 초기화하면 각 인스턴스마다 final이 붙은 멤버변수가 다른 값을 갖도록 하는 것이 가능하다.

Object 클래스

모든 클래스의 조상

Reference

'Back-End > 백기선님의 자바 스터디' 카테고리의 다른 글

7주차 - 패키지  (0) 2022.04.05
5주차 - 클래스  (0) 2022.03.20
4주차 - 제어문  (0) 2022.03.16
3주차 - 연산자  (0) 2022.03.10
2주차 - 자바 데이터 타입, 변수 그리고 배열  (0) 2022.03.10

댓글