해당 글을 백기선 님의 자바 스터디 6주차 과제를 공부하고 공유하기 위해서 작성되었습니다.

목표

자바의 상속에 대해 학습한다.

학습할 것

자바 상속의 특징

상속이란?

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

   자바에서 상속의 구현은 다음과 같다. 새로 작성하고자 하는 클래스의 이름 뒤에 상속받고자 하는 클래스의 이름을 키워드 ‘extends’와 함께 써 주기만 하면 된다.

    class Child extends Parent {
        // ...
    }

새로 작성하려는 클래스는 Child이고 기존 클래스는 Parent이다. 두 클래스는 서로 상속 관계에 있다고 하며, 상속해주는 클래스(Parent)를 ‘조상 클래스’라 하고 상속 받는 클래스(Child)를 ‘자손 클래스’라고 한다.

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

클래스 간의 상속관계를 그림으로 표현한 것을 상속계층도(class hierarchy)라고 한다. 아래와 같은 상속관계를 그림으로 표현하면 다음과 같다.

    class Parent { }
    class Child extends Parent { }


   자손 클래스는 조상 클래스의 모든 멤버를 상속받기 때문에 조상 클래스에 멤버변수가 추가되면 자손 클래스에 자동적으로 멤버변수가 추가된 것과 같은 효과를 얻는다.


반대로 자손 클래스에 새로운 무언가가 추가되어도 조상 클래스에는 아무런 영향을 주지 않는다.


   자손 클래스는 조상 클래스의 모든 멤버를 상속받으므로 항상 조상클래스보다 같거나 많은 멤버를 가진다. 즉, 상속을 거듭할수록 상속받는 클래스의 멤버 개수는 점점 늘어나게 된다.

   상속을 받는다는 것은 조상 클래스를 확장(extends)한다는 의미로 해석할 수도 있으며 상속에 사용되는 키워드가 ‘extends’인 이유이기도 하다.

  • 생성자와 초기화 블럭은 상속되지 않는다. 멤버만 상속된다.
  • 자손 클래스의 멤버 개수는 항상 조상 클래스보다 같거나 많다.

하나의 조상, 다수의 자손

   다음과 같이 하나의 조상 클래스와 다수의 자손 클래스가 있다.

    class Parent { }
    class Child1 extends Parent { }
    class Child2 extends Parent { }


클래스 Child1과 Child2가 모두 Parent클래스를 상속받고 있으므로 Parent와 Child1, Parent와 Child2는 서로 상속관계에 있지만, 자손 클래스 간에는 아무런 관계도 성립하지 않는다. 클래스 간의 관계에서 형제 관계와 같은 것은 없다.

   Child1 클래스로부터 상속받는 GrandChild라는 새로운 클래스를 추가해보자.

    class Parent { }
    class Child1 extends Parent { }
    class Child2 extends Parent { }
    class GrandChild extends Child1 { }


자손 클래스는 조상 클래스의 모든 멤버를 물려받으므로 GrandChild 클래스는 Child1 클래스의 모든 멤버와 Parent 클래스로부터 상속받은 멤버까지 상속받게 된다. 즉, GrandChild 클래스는 Parent 클래스와 간접적인 상속관계를 가지게 된다.

클래스 간의 관계 - 포함

   상속이외에도 클래스를 재사용하는 방법이 있는데, 클래스 간에 포함(Composite)관계를 맺어 주는 것이다. 클래스 간의 포함관계를 맺어 주는 것은 한 클래스의 멤버변수로 다른 클래스 타입의 참조변수를 선언하는 것이다.

   예시를 통해서 좀 더 알아보자.

    class Circle {
        int x;      // 원점 x 좌표
        int y;      // 원점 y 좌표
        int r;      // 반지름
    }

    class Point {
        int x;      // x 좌표
        int y;      // y 좌표
    }

다음과 같이 원을 표현하는 클래스 Circle과 점을 다루는 클래스 Point가 있다. Point 클래스를 재사용하여 Circle 클래스를 작성하면 아래와 같이 될 것이다.

    class Circle {
        Point c = new Point();
        int r;
    }

이처럼 한 클래스를 작성하는데 다른 클래스를 멤버변수로 선언하여 포함시킬 수 있다. 이렇게 작성하면 하나의 거대한 클래스를 작성하는 것보다 간결하고 손쉽게 클래스를 작성할 수 있다.

단일 상속(single inheritance)

   다른 객체지향언어인 C++에서는 여러 조상 클래스로부터 상속받는 다중상속을 허용하지만 자바에서는 단일 상속만을 허용한다.

    class Child extends Father, Mother { // Error. 조상은 하나만 허용
        // ...
    }

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

   자바에서는 다중상속의 이러한 문제점을 해결하기 위해 다중상속의 장점을 포기하고 단일상속만을 허용한다.

super 키워드

   super는 자손 클래스에서 조상 클래스로부터 상속받은 멤버를 참조하는데 사용되는 참조변수이다. 멤버변수와 지역변수의 이름이 같을 때 this를 붙여서 구별했듯이 상속받은 멤버와 자신의 클래스에 정의된 멤버의 이름이 같을 때는 super를 붙여서 구별할 수 있다.

   조상 클래스로부터 상속받은 멤버도 자손 클래스 자신의 멤버이므로 this를 사용할 수 있다. 조상의 멤버와 자신의 멤버를 구별하는데 사용된다는 점을 제외하고 super와 this는 근본적으로 같다. 모든 인스턴스메서드에는 자신이 속한 인스턴스의 주소가 지역변수로 저장되는데, 이것이 참조변수인 this와 super의 값이 된다.

   static 메서드(클래스 메서드)는 인스턴스와 관련이 없기 때문에 this와 마찬가지로 super 역시 static 메서드에서는 사용할 수 없고 인스턴스 메서드에서만 사용할 수 있다.

   조상 클래스에 선언된 멤버변수와 같은 이름의 멤버변수를 자손 클래스에서 중복해서 정의하는 것이 가능하며 참조변수 super를 이용해서 서로 구별할 수 있다.

    class App {
        public static void main(String[] args) {
            Child c = new Child();
            c.method;
        }
    }

    class Parent {
        int x = 10;
    }

    class Child extends Parent {
        int x = 20;

        void method() {
            System.out.println("x = " + x);
            System.out.println("this.x = " + this.x);
            System.out.println("super.x = " + super.x);
        }
    }


위 예제에서 super.x는 조상 클래스로부터 상속받은 멤버변수 x를 뜻하며, this.x는 자손 클래스에 선언된 멤버변수를 뜻한다.

   변수만이 아니라 메서드 역시 super를 써서 호출할 수 있다. 조상 클래스의 메서드를 자손 클래스에서 오버라이딩한 경우에 super를 사용한다.

    class Point {
        int x;
        int y;

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

    class Point3D extends Point {
        int z;

        String getLocation() {
            return super.getLocation() + ", z : " + z;
        }
    }

super() - 조상 클래스의 생성자

   this()와 마찬가지로 super() 역시 생성자이다. super()는 조상 클래스의 생성자를 호출하는데 사용된다.

   자손 클래스의 인스턴스를 생성하면, 자손의 멤버와 조상의 멤버가 모두 합쳐진 하나의 인스턴스가 생성된다. 그래서 자손 클래스의 인스턴스가 조상 클래스의 멤버들을 사용할 수 있는 것인데, 이 때 조상 클래스 멤버의 초기화 작업이 수행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다.

   생성자의 첫 줄에서 조상클래스의 생성자를 호출해야하는 이유는 자손 클래스의 멤버가 조상 클래스의 멤버를 사용할 수도 있으므로 조상의 멤버들이 먼저 초기화되어 있어야 하기 때문이다.

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

다음 예제는 인스턴스를 생성할 때 인스턴스의 초기화 순서를 보여주는 예제이다.

    class App {
        public static void main(String[] args) {
            Point3D p3 = new Point3D();
            System.out.println("p3.x = " + p3.x);
            System.out.println("p3.y = " + p3.y);
            System.out.println("p3.z = " + p3.z);
        }
    }

    class Point {
        int x = 10;
        int y = 20;

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

    class Point3D extends Point {
        int z = 30;

        Point3D() {
            this(100, 200, 300);
        }

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



메서드 오버라이딩

오버라이딩(overriding)이란?

   조상클래스로부터 상속받은 메서드의 내용을 변경하는 것을 오버라이딩이라고 한다. 상속받은 메서드를 그대로 사용하기도 하지만, 자손 클래스 자신에 맞게 변경해야하는 경우가 많다. 이럴 때 조상의 메서드를 오버라이딩한다.

오버라이딩의 조건

   오버라이딩은 메서드의 내용만을 새로 작성하는 것이므로 메서드의 선언부는 조상의 것과 완전히 일치해야 한다. 따라서 다음 조건을 만족해야한다.

  • 자손 클래스에서 오버라이딩하는 메서드는 조상 클래스의 메서드와
    • 이름이 같아야 한다.
    • 매개변수가 같아야 한다.
    • 반환타입이 같아야 한다.

여기서 반환타입의 경우 JDK1.5부터 공변 반환타입(covariant return type)이 추가되어, 반환타입을 자손 클래스의 타입으로 변경하는 것이 가능하도록 되었다.

위의 조건들을 간단히 요약하면 선언부가 서로 일치해야 한다는 것이다. 단 접근 제어자(access modifier)와 예외(exception)는 제한된 조건 하에서만 다르게 변경할 수 있다.

  1. 접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
    만일 조상 클래스에 정의된 메서드의 접근 제어자가 protected라면, 이를 오버라이딩하는 자손 클래스의 메서드는 접근 제어자가 protected나 public이어야 한다. 대부분의 경우 같은 범위의 접근 제어자를 사용한다.

  2. 조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없다.
    아래의 코드는 자손 클래스의 메서드에 선언된 예외의 개수가 조상 클래스의 메소드에 선언된 예외의 개수보다 적으므로 바르게 오버라이딩 되었다.

    class Parent {
        void parentMethod() throws IOException, SQLException {
            ...
        }
    }

    class Child extends Parent {
        void parentMethod() throws IOException {
            ...
        }
    }

이 때 단순히 선언된 예외의 개수의 문제가 아니라는 점에 주의해야 한다.

    class Child extends Parent {
        void parentMethod() throws Exception {
            ...
        }
    }

위와 같이 오버라이딩을 하면 조상클래스에 정의된 메서드보다 적은 개수의 예외를 선언한 것처럼 보이지만 Exception은 모든 예외의 최고 조상이므로 가장 많은 개수의 예외를 던질 수 있도록 선언한 것이다. 따라서 잘못된 오버라이딩이 된다.

  • 조상 클래스의 메서드를 자손 클래스에서 오버라이딩할 때
    1. 접근 제어자를 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
    2. 예외는 조상 클래스의 메서드보다 많이 선언할 수 없다.
    3. 인스턴스메서드를 static메서드로 또는 그 반대로 변경할 수 없다.

Q. 조상 클래스에 정의된 static메서드를 자손 클래스에서 같은 이름의 static메서드로 정의할 수 있는가?

A. 가능하다.
단, 이 경우에는 오버라이딩이 아닌 각 클래스에 별개의 static메서드를 정의한 것일 뿐이다.

오버로딩? 오버라이딩?

  • 오버로딩(overloading) 기존에 없는 새로운 메서드를 정의하는 것(new)
  • 오버라이딩(overriding) 상속받은 메서드의 내용을 변경하는 것(change, modify)
    class Parent {
        void parentMethod() { }
    }

    class Child extends Parent {
        void parentMethod() { }         // 오버라이딩
        void parentMethod(int i) { }    // 오버로딩

        void childMethod() { }
        void childMethod(int i) { }     // 오버로딩
    }

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

다형성(polymorphism)

   상속과 다형성은 객체지향개념의 중요한 특징으로 서로 깊은 관계에 있다.

   객체지향개념에서 다형성이란 여러가지 형태를 가질 수 있는 능력을 의미하며, 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 프로그램적으로 구현하였다.

   구체적으로 조상클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조할 수 있도록 한 것이다. 다음과 같은 코드가 있다.

    class Parent {
        String strP;
        int intP;

        void methodP() { }
    }

    class Child extends Parent {
        String strC;

        void methodC() { }        
    }

두 클래스는 상속관계에 있고, 두 클래스의 인스턴스를 생성하고 사용하려면 다음과 같이 해야한다.

    Parent p = new Parent();
    Child c = new Child();

생성된 인스턴스를 다루기 위해서는 인스턴스의 타입과 일치하는 타입의 참조변수만을 사용했다. 이처럼 인스턴스의 타입과 참조변수의 타입이 일치하는 것이 보통이지만, 서로 상속관계에 있을 경우 다음과 같이 조상클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조하도록 하는 것도 가능하다.

    Parent p = new Child();

   그렇다면 인스턴스를 같은 타입의 참조변수로 참조하는 것과 조상타입의 참조변수로 참조하는 것은 어떤 차이가 있을까?

    Parent p = new Child();
    Child c = new Child();

Parent타입의 참조변수로는 Child인스턴스 중에서 Parent클래스의 멤버들(상속받은 멤버 포함)만 사용할 수 있다. 둘 다 같은 타입의 인스턴스지만 참조변수의 타입에 따라 사용할 수 있는 멤버의 개수가 달라지는 것이다.


반대의 경우, 자손타입의 참조변수로 조상타입의 인스턴스를 참조하는 것은 불가능하다. 그 이유는 실제 인스턴스의 멤버 개수보다 참조변수가 사용할 수 있는 멤버 개수가 더 많기 때문이다.

메소드 디스패치

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

  • Static Dispatch
    컴파일 시점에서, 컴파일러가 특정 메소드를 호출할 것이라고 명확하게 알고있는 경우이다.
    class Dispatch {
        static class Service {
            void run() {
                System.out.println("run");
            }

            void run(String msg) {
                System.out.println(msg);
            }
        }

        public static void main(String[] args) {
            new Service().run();
        }
    }
  • Dynamic Dispatch
    정적 디스패치와 반대로 컴파일러가 어떤 메소드를 호출하는지 모르는 경우이다. 동적 디스패치는 호출할 메서드를 런타임 시점에서 결정한다.
    class Dispatch {
        static abstract class Service {
            abstract void run();
        }

        static class MyService1 extends Service {
            @Override
            void run() {
                System.out.println("1");
            }
        }

        static class MyService2 extends Service {
            @Override
            void run() {
                System.out.println("2");
            }
        }

        public static void main(String[] args) {
            Service srv = new MyService1();
            srv.run();
        }
    }

추상 클래스

추상클래스(abstract class)란?

   클래스가 설계도라면, 추상 클래스는 미완성 설계도라고 할 수 있다. 클래스가 미완성이라는 뜻은 멤버의 개수에 관계된 것이 아니라, 단지 미완성메서드(추상메서드)를 포함하고 있다는 의미이다.

   미완성 설계도로 제품을 만들 수 없듯이 추상클래스로 인스턴스는 생성할 수 없다. 추상클래스는 상속을 통해서 자손클래스에 의해서만 완성될 수 있다.
추상클래스 자체로는 클래스로서의 역할을 못하지만, 새로운 클래스를 작성하는데 바탕이 되는 조상클래스로서 중요한 의미를 가진다.

   추상클래스는 키워드 ‘abstract’를 붙이기만 하면 된다. 클래스 선언부의 abstract을 보고 이 클래스에는 추상메서드가 있으니 상속을 통해 구현해야 한다는 것을 쉽게 알 수 있다.

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

추상클래스는 추상메서드를 포함하고 있다는 것을 제외하고 일반 클래스와 동일하므로, 생성자가 있고, 멤버변수와 메서드도 가질 수 있다.

추상클래스의 작성

   추상클래스를 작성할 때 여러 클래스에 공통적으로 사용될 수 있는 클래스를 바로 작성하기도 하고, 기존 클래스의 공통적인 부분을 뽑아서 추상클래스로 만들어 상속하도록 하는 경우도 있다.

   상속계층도를 따라 내려갈수록 클래스는 점점 기능이 추가되어 구체화의 정도가 커지며, 반대로 올라갈수록 추상화의 정도가 커진다고 할 수 있다. 즉, 상속계층도를 따라 내려 갈수록 세분화되며, 올라갈수록 공통요소만 남게 된다.

  • 추상화 : 클래스간의 공통점을 찾아내서 공통의 조상을 만드는 작업
  • 구체화 : 상속을 통해 클래스를 구현, 확장하는 작업

클래스의 메서드를 추상메서드로 하는 대신, 아무 내용도 없는 메서드로 작성할 수도 있다. 자손클래스에서 오버라이딩하여 자신의 클래스에 맞게 구현하기 때문에 굳이 추상메서드를 사용할 필요가 없다고 생각할 수도 있다.
만일 추상메서드로 정의되지 않고 빈 몸통만 가지고 있다면 상속받는 자손클래스에서는 이 메서드가 온전히 구현된 것으로 인식하고 오버라이딩을 하지 않을수도 있다. 그렇기 때문에 추상메서드로 선언하여 자손클래스에게 내용을 구현해주어야 한다는 것을 알려주는 것이다.

final 키워드

   final은 ‘마지막의’ 또는 ‘변경될 수 없는’의 의미를 가지고 있으며 거의 모든 대상에 사용될 수 있다.

   변수에 사용되면 값을 변경할 수 없는 상수가 되며, 메서드에 사용되면 오버라이딩을 할 수 없게 되고 클래스에 사용되면 자신을 확장하는 자손클래스를 정의하지 못하게 된다.

제어자 대 상 의 미
final 클래스 변경될 수 없는 클래스, 확장될 수 없는 클래스가 된다.
따라서 final로 지정된 클래스는 다른 클래스의 조상이 될 수 없다.
메서드 변경될 수 없는 메서드, final로 지정된 메서드는 오버라이딩을 통해 재정의 될 수 없다.
멤버변수 변수 앞에 final이 붙으면, 값을 변경할 수 없는 상수가 된다.
지역변수
    final class FinalTest {             // 조상이 될 수 없는 클래스
        final int MAX_SIZE = 10;        // 값을 변경할 수 없는 멤버변수(상수)

        final void getMaxSize() {       // 오버라이딩할 수 없는 메서드(변경불가)
            final int LV = MAX_SIZE;    // 값을 변경할 수 없는 지역변수(상수)
            return MAX_SIZE;
        }
    }

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

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

   클래스 내에 매개변수를 갖는 생성자를 선언하여, 인스턴스를 생성할 때 final이 붙은 멤버변수를 초기화하는데 필요한 값을 생성자의 매개변수로부터 제공받는 것이다. 이 기능을 활용하면 각 인스턴스마다 final이 붙은 멤버변수가 다른 값을 갖도록 하는 것이 가능하다.

    class Card {
        final int NUMBER;                // 상수지만 선언과 함께 초기화 하지 않고
        final String KIND;               // 생성자에서 단 한번만 초기화할 수 있다.
        static int width = 100;
        static int height = 250;

        // 매개 변수로 넘겨받은 값으로 KIND와 NUMBER를 초기화한다.
        Card(String kind, int num) {
            KIND = kind;
            NUMBER = num;
        }

        Card() {
            this("HEART", 1);
        }

        public String toString() {
            return KIND + " " + NUMBER;
        }
    }

    class FinalCardTest {
        public static void main(String[] args) {
            Card c = new Card("HEART", 10);
    //      c.NUMBER = 5; Error. cannot assign a value to final variable NUMBER
            System.out.println(c.KIND);
            System.out.println(c.NUMBER);
            System.out.println(c);  // System.out.println(c.toString());
        }
    }

Object 클래스

모든 클래스의 조상

   Object클래스는 모든 클래스 상속계층도의 최상위에 있는 조상클래스이다. 다른 클래스로부터 상속 받지 않는 모든 클래스들은 자동적으로 Object클래스로부터 상속받게 함으로써 이것을 가능하게 한다.

   아래와 같이 다른 클래스로 부터 상속받지 않는 클래스를 정의하면 코드를 컴파일 할 때 컴파일러에서 자동적으로 extends Object를 추가하여 상속받도록 한다.

    class Example {
        ...
    }
    class Example extends Object {
        ...
    }

   toString(), equals()와 같은 메서드를 따로 정의하지 않고 사용할 수 있었던 이유는 이 메서드들이 Object클래스에 정의된 것들이기 때문이다. Object클래스에는 모든 인스턴스가 가져야 할 기본적인 11개의 메서드가 정의되어 있으며 그 목록은 다음과 같다.

Object클래스의 메서드 설 명
protected Object clone(   ) 객체 자신의 복사본을 반환한다.
public boolean equals(Object obj) 객체 자신과 객체 obj가 같은 객체인지 알려준다.(같으면 true)
protected void finalize() 객체가 소멸될 때 가비지 컬렉터에 의해 자동적으로 호출된다.
이 때 수행되어야하는 코드가 있을 때 오버라이딩한다.(거의 사용되지 않음)
public Class getClass() 객체 자신의 클래스 정보를 담고 있는 Class인스턴스를 반환한다.
public int hashCode() 객체 자신의 해시코드를 반환한다.
public String toString() 객체 자신의 정보를 문자열로 반환한다.
public void notify() 객체 자신을 사용하려고 기다리는 쓰레드를 하나만 깨운다.
public void notifyAll() 객체 자신을 사용하려고 기다리는 모든 쓰레드를 깨운다.
public void wait()
public void wait(long timeout)
public void wait(long timeout, int nanos)
다른 쓰레드가 notify()나 notifyAll()을 호출할 때까지 현재 쓰레드를 무한히 또는 지정된 시간(timeout, nanos)동안 기다리게 한다. (timeout은 천 분의 1초, nanos는 109분의 1초)

Reference