상세 컨텐츠

본문 제목

5. 상속 _ 물려 받아 사용하자

How To Java/Java Tutorial

by 카페코더 2020. 3. 21. 18:47

본문

반응형

취업 준비를 하며 복습한다는 마음으로, 이것을 보는 누군가에게
도움이 되었으면 하는 마음으로 Java-Tutoral을 작성해 봅니다.
입문서와 순서가 잘못되었을 수 있고, 제가 아는 정보가 틀렸을 수 있습니다.
이 글에 대한 잘못된 정보나, 오탈자 등 수정해야 할 항목 혹은
추가해야 할 항목은 댓글로 알려주시면 감사하겠습니다.

이 자료가 올라가는 저장소 : https://github.com/hwk0911/Java-tutorial

 

hwk0911/Java-tutorial

Java tutorial. Contribute to hwk0911/Java-tutorial development by creating an account on GitHub.

github.com

이번 포스팅은 상속에 대해 알아보려 한다.
만약, 클래스에 대한 개념이 부족하다 생각된다면,
4. 객체지향 프로그래밍의 시작 "클래스" (Java 클래스)를 먼저 참조하자.

1. 상속의 개념

 

SBS 드라마 상속자들

먼저 상속(相續)이란 사람의 사망에 의한 재산 및 신분상의 지위의 포괄적인 승계를 말한다.
(출처 : 위키백과)

위는 일반적인 상속에 대한 의미고,
OOP(Object-Oriented Programming)에서 상속은 객체들 간의 관계를 구축하는 방법이다.

쉽게 얘기해 사전적 의미의 상속은 대개 부모와 자식 사이에서 일어나는데,
부모에게서 받은 재산을 자식이 상속받으면 자식의 것이 된다.

OOP에서의 상속도 마찬가지다.
부모 클래스(Parent class)로 부터 자식 클래스(children class)로의 상속이 발생하며,
접근 제한자에 따라 자식 클래스는 부모 클래스의 메서드나 필드를 사용할 수 있다.

부모 클래스의 private로 선언된 메서드나 필드는 사용할 수 없으며,
부모 클래스가 다른 패키지에 있고, default로 선언되어 있다면 사용할 수 없다.

4장에서 설명한 protected의 경우 다른 패키지의 접근을 제한하지만,
자식 클래스의 접근은 허용한다.

1.1 상속의 특징

  1. 부모 클래스는 자식 클래스의 자원 사용이 불가능하다.
  2. 부모 : 자식 = 1 : N 은 가능하다. 하지만,
    부모 : 자식 = N : 1 은 불가능하다.
  3. 부모 클래스가 상속받은 자원 역시 상속받은 자식 클래스가 활용 가능하다.

2. 클래스 상속

클래스의 상속은 extends를 통해 이뤄진다.

예제 코드를 살펴보자.

class Tool {
    public String ToolFeature () {
        return "사용이 가능하다.";
    }
}

class WritingTool extends Tool{
    public String WrithingToolFeature () {
        return "필기가 가능하다.";
    }
}

class Main {
    public static void main(String[] args) {
        WritingTool writingTool = new WritingTool();

        System.out.println(writingTool.ToolFeature());
        System.out.println(writingTool.WrithingToolFeature());
    }
}

/*
결과 : 
사용이 가능하다.
필기가 가능하다.

Process finished with exit code 0
 */

WritingTool의 객체 writingTool이 Tool의 기능을 문제없이 사용하는 것을 볼 수 있다.

하지만 다음 코드를 살펴보자.

class Tool {
    private String ToolFeature () {
        return "사용이 가능하다.";
    }
}

class WritingTool extends Tool{
    public String WrithingToolFeature () {
        return "필기가 가능하다.";
    }
}

class Main {
    public static void main(String[] args) {
        WritingTool writingTool = new WritingTool();

        System.out.println(writingTool.ToolFeature());
        System.out.println(writingTool.WrithingToolFeature());
    }
}

/*
결과 :
Error:(17, 39) java: cannot find symbol
  symbol:   method ToolFeature()
  location: variable writingTool of type WritingTool
 */

이번 코드의 경우는 에러가 발생한다. 원인은 writingTool이 사용한 메서드가
private으로 선언되어 있기 때문이다.

이렇듯, private으로 선언된 메서드나 필드, 혹은 다른 패키지에 있는 default로 선언된
메서드나 필드의 경우는 사용하지 못한다.

3. 부모 생성자 호출하기

기본적으로 모든 클래스는 생성자를 갖고 있다. A라는 클래스를 호출하는 간단한 코드를 보자.

class A {
	Integer number;

	A () {
    	this.number = 0;
    }
}

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

A클래스를 선언하고, 생성자의 역할은 A클래스의 필드 number를 0으로 초기화한다.
Main 클래스 내의 main에서 A클래스의 객체 a를 선언하였고, new A();로 초기화하였다.
이때 생성과 동시에 실행된 것이 A클래스의 생성자 A () { this.number = 0 }이다.

그렇다면 다음 코드를 보자.

class A {
    Integer number;

    A () {
        this.number = 0;
    }
}

class B extends A {
    Integer number_2;

    B () {
        this.number_2 = 1;
    }

    public Integer getNumber () {
        return this.number_2;
    }

    public Integer getParentNumber () {
        return this.number;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.getNumber());
        System.out.println(b.getParentNumber());
    }
}

/*
결과 :
1
0

Process finished with exit code 0
 */

A클래스를 상속받는 B클래스를 선언한 뒤, 메인에서 선언하여 getter를 통해
자식의 number_2와 부모의 number 필드를 출력한다.

자식 클래스의 생성자를 선언하였을 때, 부모 클래스의 생성자는
명시하지 않고 사용해도, 자식 클래스의 생성자 시작 전에 실행된다.

결과는 1, 0이 나왔다. 하지만 이 경우는 A의 생성자가 기본 생성자라 가능한 경우다.

그렇다면 다음 코드는 어떻게 되는지 보자.

class A {
    Integer number;

    A (Integer number) {
        this.number = number;
    }
}

class B extends A {
    Integer number_2;

    B () {
        this.number_2 = 1;
    }

    public Integer getNumber () {
        return this.number_2;
    }

    public Integer getParentNumber () {
        return this.number;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.getNumber());
        System.out.println(b.getParentNumber());
    }
}

/*
결과 :
Error:(12, 10) java: constructor A in class A cannot be applied to given types;
  required: java.lang.Integer
  found: no arguments
  reason: actual and formal argument lists differ in length
 */

바로 에러가 발생하게 된다. 위에 서술했듯 자식 클래스의 생성자를 선언하였을 때,
부모 클래스의 생성자는 명시하지 않고 사용해도, 자식 클래스의 생성자 시작 전에 실행된다 했다.

부모 클래스의 생성자가 파라미터가 없는 기본 생성자가 존재하는 경우, 문제없이 실행이 가능하지만,
기본 생성자가 없이 파라미터를 갖는 생성자만을 갖고 있다면 실행할 수 없는 이유다.

이것을 해결하기 위해 우리는 super()를 사용하게 된다.

다음 코드를 보자.

class A {
    Integer number;

    A (Integer number) {
        this.number = number;
    }
}

class B extends A {
    Integer number_2;

    B () {
        super(55);
        this.number_2 = 1;
    }

    public Integer getNumber () {
        return this.number_2;
    }

    public Integer getParentNumber () {
        return this.number;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.getNumber());
        System.out.println(b.getParentNumber());
    }
}

/*
결과 :
1
55

Process finished with exit code 0
 */

this 키워드가 자신을 가리킨다면, super 키워드는 자신의 부모 클래스를 가리킨다.
this()는 자신의 생성자를 가리킨다면, super()는 부모 클래스의 생성자를 가리킨다.

super를 명시하지 않았다면, 기본적으로 super()를 사용한다 인식하며,
super를 명시하여 파라미터를 선언하여 사용했다면, 해당하는 부모 클래스의 생성자가 실행된다.

4. 상속받은 메서드 재정의

코딩은 항상 많은 것을 생각하며 진행해야 한다. 가장 기초적인 버그를 줄이기 위한 방법이다.
(그래도 역시 프로그래밍의 품질을 높이고 버그를 줄이는 방법은 TDD가 확실하다 생각한다.)

마찬가지로 우리가 여러 생각을 하며 코딩을 한다면, 부모 클래스의 메서드를 그대로 사용할 수 없는
경우가 생긴다. 그렇다고 부모 클래스의 해당 메서드를 수정하자니 그것을 사용하는 자식 클래스가
너무 많아 어떤 에러가 발생할지 모르는 상황이 된다.

그래서 우리는 @Override 어노테이션을 사용한다.

정말 억지지만, 오버 라이딩이 필요한 코드를 보자.

class A {
    Integer number;
    Integer number_2;

    A (Integer number, Integer number_2) {
        this.number = number;
        this.number_2 = number_2;
    }

    public Integer retAnswer () {
        return this.number + number_2;
    }
}

class B extends A {
    Integer number;

    B () {
        super(55, 45);
        this.number = 1;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.retAnswer());
    }
}

/*
결과 :
100

Process finished with exit code 0
 */

부모 클래스 A에는 A의 필드인 두 Integer 변수를 더하여 반환하는 함수가 있다.
하지만 유독 이 코드에서는 두 변수의 합이 아닌, 두 변수의 차이를 구해야 한다.

부모 클래스의 메서드를 수정하자니 상속받고 있는 다른 자식들이 걱정이 된다.

여러 방법이 있겠지만, 우리는 @Override를 통해 원하는 코드로 바꿔 사용하기로 한다.

class A {
    Integer number;
    Integer number_2;

    A (Integer number, Integer number_2) {
        this.number = number;
        this.number_2 = number_2;
    }

    public Integer retAnswer () {
        return this.number + number_2;
    }
}

class B extends A {
    Integer number;

    B () {
        super(55, 45);
        this.number = 1;
    }

    @Override
    public Integer retAnswer() {
        return super.number - super.number_2;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.retAnswer());
    }
}

/*
결과 :
10

Process finished with exit code 0
 */

오버 라이딩을 통해 부모 클래스의 retAnswer() 메서드를 두 변수의 차이를 구하는 함수로
재정의하여 사용했다. 

결과는 55 - 45의 결과인 10이 출력된 것을 볼 수 있다.

간단한 코드라 왜 오버 라이딩을 사용해야 하는지 아직 이해가 덜 되었을 수 있다.
그렇다면 부모 클래스가 retAnswer() 메서드만을 갖는 것이 아닌, 저것을 참조하는
무수히 많은 부모 클래스의 메서드가 존재한다 생각해보자. 

만약 오버 라이딩이 아닌, 부모 클래스의 retAnswer() 메서드를 직접 수정한다면,
오히려 하나의 기능을 구현하기 위해 대부분의 코드를 수정해야 하는 경우가 생길 수 있다.

그리고 부모 클래스의 메서드뿐 아닌, 내장 메서드를 수정할 수 있다.

// 우리에게 친근한 내장 메소드인 toString()으로 살펴보자.

class A {
    Integer number;
    Integer number_2;

    A (Integer number, Integer number_2) {
        this.number = number;
        this.number_2 = number_2;
    }

    public Integer retAnswer () {
        return this.number + number_2;
    }
}

class B extends A {
    Integer number;

    B () {
        super(55, 45);
        this.number = 1;
    }

    @Override
    public Integer retAnswer() {
        return super.number - super.number_2;
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.retAnswer());
        System.out.println(b.toString());
    }
}

/*
결과 :
10
B@1b6d3586

Process finished with exit code 0
 */

내장 메서드인 toString을 통하여 b.toString()을 출력해보면, "B@1 b6 d3586"가 출력된다.
컴퓨터가 좋아할 만한 내용이 출력된다. 하지만 우리가 좋아할 만한 데이터는 아니다.

Override를 통해서 우리가 필요한 내용을 출력하도록 바꿔보자.
간단하게 필드의 정보를 불러와보자.

class A {
    Integer number;
    Integer number_2;

    A (Integer number, Integer number_2) {
        this.number = number;
        this.number_2 = number_2;
    }

    public Integer retAnswer () {
        return this.number + number_2;
    }
}

class B extends A {
    Integer number;

    B () {
        super(55, 45);
        this.number = 1;
    }

    @Override
    public Integer retAnswer() {
        return super.number - super.number_2;
    }

    @Override
    public String toString() {
        return "B 클래스의 필드 [Integer number : " + this.number + " ]";
    }
}

class Main {
    public static void main(String args[]) {
        B b = new B();

        System.out.println(b.retAnswer());
        System.out.println(b.toString());
    }
}

/*
결과 :
10
B 클래스의 필드 [Integer number : 1 ]

Process finished with exit code 0
 */

결과가 "B@1 b6 d3586"에서 "B 클래스의 필드 [Integer number : 1 ]"로 변한 것을 알 수 있다.
이렇게 우리가 사용하기 좋은 형태로 오버 라이딩하여 사용할 수 있다.

5. final 클래스와, final 메서드

지금까지 Java Tutorials를 모두 봐주신 감사한 분들이라면, final에 대해 어느 정도 알고 있을 것이다.
(아직 못 봤다면 "4. 객체지향 프로그래밍의 시작 "클래스" (Java 클래스)"를 참조하자.)

5.1 final 클래스

final 클래스는 상속을 금지한다.

단순히 생각하면, 내가 만든 클래스가 있는데, 이것을 누군가 사용하는 것이 싫어
final로 선언하여 상속을 막아놓는 경우를 예로 들 수 있다.

다른 관점에서 생각해보자면, 보안상의 이유로 사용한다.

여러 Java 참고도서를 살펴보면, 중요한 class를 final로 선언하지 않는다면,
중요한 class의 sub class를 만들어 시스템을 파괴하도록 할 수 있기 때문이다.
따라서 이론상으로는 중요한 class는 final class로 선언하여 사용한다.

+ 대표적인 final class의 예시로는 String class를 들 수 있다.
따라서 String class의 메서드를 오버 라이딩하여 사용할 수 없다.

여기서 의문점이 들 수 있다.
"toString()은 오버 라이딩하여 사용하였는데, 저거는 어떻게 가능한 건가?" 

Java의 구조는 class의 계층구조로 되어있는데, 모든 class의 최상단에는 Object class가 존재한다.
toString은 Object의 메서 드지, String class의 메서드가 아니다.

5.2 final 메서드

위에서 final class는 클래스 자체의 상속을 금지한다 했다.

final 메서드는 final로 선언된 class가 아닌 경우 상속을 불가능하도록 하는 메서드를 선언할 때
사용된다. 

참 쉽죠?

6. 추상 클래스

드디어 추상 클래스가 나왔다. C에서 C++로 넘어가는 시점에서 class의 개념이 너무 어려웠다.
class와 object에 대한 개념을 이해하다 보니 추상 클래스라는 큰 벽에 오래 막혀있었다.

이 파트에서는 추상 클래스와 유사한 인터페이스에 대해 같이 다루려 한다.

6.1 추상 클래스 (Abstract class) 개념

추상 메서드를 하나 이상 갖는 클래스를 의미한다.
모두 추상 메서드일 수 있고, 대부분 기능 구현이 되어있고, 하나만 추상 메서드일 수 있다.

6.2 인터페이스 (Interface) 개념

인터페이스는 여러 클래스들의 공통점을 모아 함수의 껍데기만 명시한,
즉 구현하고 싶은 클래스의 청사진이라 볼 수 있다.

사용하는 이유는 Java는 다중 상속을 지원하지 않기 때문이다.

6.3 추상 클래스와 인터페이스의 차이

추상 클래스와 인터페이스는 상당히 유사하다. 하지만 목적은 너무나 다르다.

추상 클래스의 목적은 이것을 상속받아 다른 클래스에서 추상 클래스의 기능을 사용하고,
더 나아가 확장하는 데 있다. 사용하는 클래스는 하나의 추상 클래스만을 상속받는다.

반면, 인터페이스의 목적은 구현하는 클래스가 인터페이스 내의 메서드를 강제 구현하게
하기 위해서 사용된다. 구현을 강제해 객체의 같은 동작을 보장한다.
구현하는 클래스는 여러 개의 인터페이스를 구현할 수 있다.

7. 다형성

프로그램 언어 다형성(多形性, polymorphism; 폴리모피즘)은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, , 오브젝트, 함수, 메서드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다. 반대말 은단 형성(monomorphism)으로, 단형성(monomorphism 프로그램 언어의 각 요소가 한 가지 형태만 가지는 성질을 가리킨다. (출처 : 위키백과)

세 줄 이상 못 읽는 분들이 계셔서 요약하자면,
다형성이란, 여러 형태를 갖는 성질을 의미하며, JAVA에서는 한 타입의 참조 변수로 여러
타입의 객체를 참조할 수 있도록 하는 성질을 의미한다.

더 줄여보면,
하나의 메서드나 클래스가 있을 때, 이것들이 다양한 방법으로 동작하는 것을 의미한다.

다형성을 구현하는 방법은 부모 클래스의 객체에서 자식 멤버를 참조하여, 다형성을 구현한다.

단순히 순서로 생각해보면,

  1. 부모 클래스 또는 인터페이스 선언
  2. 부모 클래스를 상속받거나, 인터페이스를 구현하여 클래스를 선언
  3. 부모 클래스 또는 인터페이스의 객체를 선언
  4. 생성된 객체를 자식 클래스 또는 구현된 클래스로 선언

이해가 안 된다면 클래스의 친구 붕어빵을 예로 들어보자.

interface FishBread {
    void base ();
    void jam ();
    void name ();
}

class ChouCreamFishBread implements FishBread{
    String base;
    String jam;
    String name;

    ChouCreamFishBread() {
        this.base();
        this.jam();
        this.name();
    }

    @Override
    public void base() {
        this.base = "마가린 + 밀가루";
    }

    @Override
    public void jam() {
        this.jam = "chouCream";
    }

    @Override
    public void name() {
        this.name = "슈크림 붕어빵";
    }

    @Override
    public String toString() {
        return "이름 : " + this.name + "\n"
                + "베이스 : " + this.base + "\n"
                + "내용물 : " + this.jam;
    }
}

class BasicFishBread implements FishBread{
    String base;
    String jam;
    String name;

    BasicFishBread () {
        this.base();
        this.jam();
        this.name();
    }

    @Override
    public void base() {
        this.base = "마가린 + 밀가루";
    }

    @Override
    public void jam() {
        this.jam = "redBean";
    }

    @Override
    public void name() {
        this.name = "팥붕어빵";
    }

    @Override
    public String toString() {
        return "이름 : " + this.name + "\n"
                + "베이스 : " + this.base + "\n"
                + "내용물 : " + this.jam;
    }
}

class Main {
    public static void main(String[] args) {
        FishBread fishBread;

        fishBread = new ChouCreamFishBread();
        System.out.println(fishBread.toString());

        fishBread = new BasicFishBread();
        System.out.println(fishBread.toString());
    }
}

/*
이름 : 슈크림 붕어빵
베이스 : 마가린 + 밀가루
내용물 : chouCream
이름 : 팥붕어빵
베이스 : 마가린 + 밀가루
내용물 : redBean

Process finished with exit code 0
 */

우선 위에 서술한 다형성을 구현하는 순서에 맞춰 생각해보자.

  1. 부모 클래스 또는 인터페이스 선언
    - 붕어빵의 기본인 FishBread 인터페이스를 선언
  2. 부모 클래스를 상속받거나, 인터페이스를 구현하여 클래스를 선언
    - FishBread를 구현한 ChouCreamFishBread 클래스와, BasicFishBread 클래스 선언
  3. 부모 클래스 또는 인터페이스의 객체를 선언
    - FishBread의 객체 fishBread 선언
  4. 생성된 객체를 자식 클래스 또는 구현된 클래스로 선언
    1. fishBread = new ChouCreamFishBread();
    2. fishBread = new BasicFishBread();

이런 방식의 예로 List를 들 수 있다.
List는 인터페이스로, List <String> stringList = new List <>(); 와 같은 형태로 선언할 수 없다.
그래서 보편적으로 List <String> stringList = new ArrayList <>(); 같은 형태로 선언하여 사용한다.
(new List <>();로 사용하려면 인터페이스를 모두 구현해야 한다.)

이렇게 다형성을 사용하는 방법에 대해 간단하게 알아봤다. 
그렇다면 왜 쓰는지 이유라도 알아야 후에 사용할 것이다.

다형성 사용의 이유는 다음과 같다.

  1. 기존의 소스코드를 재활용
  2. 깔끔한 코드를 추가할 수 있음.

 

이것으로 상속에 대한 포스팅을 마치겠다.

개인적으로 객체지향 프로그래밍에서의 가장 어려운 파트라 생각된다.
특히 추상 클래스, 인터페이스가 그렇다. 이해하는데 정말 한참 걸렸다.

 

반응형

관련글 더보기

GitHub 댓글

댓글 영역