상속과 다형성, 인터페이스와 추상 클래스

홍예석·2023년 1월 25일
0

기초 학습

목록 보기
4/5

상속

  • 상속
    자식 클래스의 뒤에 extends를 이용하여 부모 클래스의 멤버를 받아오는 것으로, 기존 클래스를 이용해 새로운 클래스를 작성하는 것.

상속을 왜 쓰는가?

이는 아래 Tv 예제를 이용해 이해해 볼 수 있다.

class Tv{
    boolean power;
    int channel;
    
    void power() {
        power = !power;
    }
    void channelUp(){
        channel++;
    }
    void channelDown(){
        channel--;
    }
}

class SmartTv extends Tv{
	//자막
    boolean caption;
    void displayCaption(String text){
        if(caption){
            System.out.println(text);
        }
    }
}

여기서 SmartTv는 Tv클래스를 상속받고 있다. 앞서 자식 클래스는 부모 클래스의 멤버를 받아온다고 했다. 부모 클래스인 Tv는 아래와 같은 멤버를 갖고 있다.

power, channel, power(), channelUp(), channelDown()

그렇다면, 자식 클래스의 멤버는 아래와 같게 된다.

power, channel, power(), channelUp(), channelDown(), caption, displayCaption(String text)

여기서 상속의 장점이 나타난다. 한 클래스 전체가 다른 클래스의 멤버로 인정이 된다면, 새로운 클래스에 똑같은 멤버를 한 번 더 선언해 줄 필요 없이, extends 키워드를 이용해 상속받게 하면 코드를 훨씬 간결하게 작성할 수 있게 된다.

상속 관계와 포함 관계

  그렇다면, 한 클래스가 다른 클래스의 멤버를 모두 가질 필요성이 있다면, 상속을 통해 코드를 간결하게 할 수 있다는 내용까지 들어왔다. 하지만 이는 엄밀히 말하면 정확한 설명이 아니다. 상속이라는 말을 다시 잘 생각해 보자. 한 클래스가 다른 클래스의 멤버를 모두 가질 필요가 있다고 해도, 항상 상속을 써서는 안 된다.
  Tv의 예시를 조금 바꿔보자, SmartTv는 분명 Tv의 한 종류이기 때문에 당연히 상속을 이용해 클래스를 작성해도 전혀 위화감이 들지 않는다. 하지만 Tv의 기능도 갖고 있는 Handphone이라는 클래스를 만들어야 한다고 치자, 핸드폰 역시 Tv의 기능을 할 수 있고 Tv가 갖고 있는 모든 멤버를 필요로 한다. 하지만 Handphone 클래스를 만들 때 extends를 이용해 Tv를 상속받아 활용해서는 안 된다. 물론 이렇게 구현해도 위 예시에서는 똑같이 기능하겠지만, 전혀 객체지향적이지 않다. 왜냐하면 Handphone은 Tv가 아니기 때문이다. 이 경우, Handphone은 Tv를 포함하고 있다고 해야 하지, 상속받고 있다고 하면 안 된다.
  즉, 상속 관계는 직관적으로 우리가 "'자식 클래스'는 '부모 클래스'다"라고 말할 수 있는 관계에서만 사용하는 것이 올바른 객체지향 관점에서의 프로그래밍이 되는 것이다. 이 문장이 성립하는 경우에만 상속을 활용할 수 있는 것은 아니지만, 우선은 상속을 공부하는 첫 계단에서는 이렇게 이해하는 것이 오류를 범하지 않기 위한 가장 기본적인 설명이다.
  가장 쉽게 범할 수 있는 논리적 오류로, 다중 상속이라는 개념이 있다. 클래스 간 상속은 단일 상속만을 허용한다. 자식 클래스가 여러 부모 클래스를 상속받는다면, Diamond Problem이라는 오류를 범하게 된다. Diamond Problem은 A 클래스를 상속받는 B클래스와 C클래스, B클래스와 C클래스를 모두 상속받는 D클래스가 있다고 하면, B와 C가 A로부터 각각 상속받은 멤버를 D가 활용할 때 B클래스와 C클래스 중 어떤 클래스의 멤버를 가져와야 하는지 명확하지 않는 오류를 말한다.
  그렇다면 다중 상속이라는 건 자바에는 없는 개념인지 묻는다면 그건 아니다. 다중 상속을 허용하기 위한 개념으로 추상 클래스라는 개념이 등장했고, 이는 뒤에서 다룰 예정이다.

오버라이딩

  • 오버라이딩
    오버라이딩은 상속받은 조상의 메서드를 자신에게 맞게 변경하는 것

오버라이딩은 왜 하는가?

  위의 정의만 봐서는 오버라이딩이라는 개념이 잘 와닿지 않는다. Tv의 예제를 다시 이용해 보자 A회사에서는 채널을 실수 범위에서 다루는 Tv를 제작했고, 이에 따라 부모 클래스인 Tv는 channel변수의 자료형을 double로 변경하였다. double은 int범위를 포함하고 있기 때문에 기존의 채널들을 다루는 데 오류가 발생하지 않는다. 따라서 B회사는 기존의 방식 그대로 정수 범위에서 채널을 송출한다고 한다.

class Tv{
    boolean power;
    //채널이 실수 범위까지로 변경되었다.
    double channel;
    
    void power() {
        power = !power;
    }
    void channelUp(){
        channel++;
    }
    void channelDown(){
        channel--;
    }
}

class ACompanyTv extends Tv{
    @Override
    void channelUp(){
        channel += 0.1;
    }
}
class BCompanyTv extends Tv{

}

  이 경우 channelUp은 기존에 채널을 1 증가시키는 것이었지만, ACompany하나 때문에 Tv클래스의 channelUp을 모두 0.1씩 증가하도록 변경할 수는 없다. 기존에 정수 범위에서 채널을 다루던 BCompany와 같은 다른 회사들이 많기 때문이다. 따라서 ACompany는 channelUp을 오버라이딩하여 자신만의 채널 관리 방법을 활용하도록 했다. 즉 channelUp이라는 메서드 자체는 Tv라면 필요한 기능이지만, 자식 클래스에서 특수하게 다르게 활용되는 것이기에 오버라이딩된 메서드는 호출될 때 부모 클래스가 아닌 자식 클래스에서 호출되도록 하는 것이다.
  비슷한 개념을 앞서 한 차례 배운 적이 있다. 오버로딩이라는 개념이다. 오버로딩과 오버라이딩이 이름이 비슷해서 헷갈릴 수 있는데, 오버로딩은 '하나의 독립적인 클래스' 내에서 같은 이름의 메서드를 매개 변수를 다르게 하여 여럿 선언하는 것이고, 오버라이딩은 '부모 클래스'의 메서드를 '자식 클래스'에서 다시 선언하는 것이다. 즉, 오버로딩은 하나의 클래스 안에 같은 이름의 메서드가 둘 이상 선언되는 반면 오버라이딩은 부모 클래스와 자식 클래스에 각각 같은 이름의 메서드가 선언되는 것이다.

super와 super()

  • super
    super는 부모 클래스를 참조하고자 할 때 활용되는 참조변수다.
    이 역시 예제를 통해 알아보자
class Main{
    int income = 1000000;
}

class Branch extends Main{
    int income = 2000000;
    void increaseIncome(){
        super.income += this.income;
    }
}

  여기서 기존에 Main본사의 수익이 1000000이었는데, branch지점에서 수익을 2000000올렸다고 치자. 그렇다면 branch지점의 수익을 Main의 income에 포함시켜야 할 것이다. 여기서 부모 클래스인 Main의 멤버인 income에 자식 클래스인 Branch에서 접근해 증가시켜 주어야 하는 상황이 됐다. 이 때 부모 클래스에 접근하는 참조 변수가 super인 것이다. super.income은 Main의 income이고, this.income은 Branch의 income이다. 이처럼 자식 클래스에서 부모 클래스에 접근할 수 있다.
  다만, 주의할 점은 이 예시는 super를 통해 부모 클래스에 접근이 가능하다는 점을 이해하기 위해 만들어낸 예시다. branch가 늘어날 때마다 Main의 income이 branch마다의 income을 반영하지 않고 1000000으로 고정되기도 하고, 수익 증가를 공유하기 위해 static을 쓸 경우 super가 아닌 Main으로 참조를 해야 하기 때문에 branch를 한 개로 고정하여 일부러 super를 쓴 예시다. 이 예시에서 얻어갈 점은, 부모 클래스를 참조하는 super라는 개념 그 자체일 뿐이다.

  • super()
    super()는 부모 클래스의 생성자를 호출할 때 활용되는 메서드다.

  앞서 배웠던 this()와 대응되는 개념이다. this()역시 자기 자신 클래스 내 생성자를 그대로 호출하는 메서드였다. super() 역시 부모 클래스의 생성자를 호출하는 메서드다. 따라서 super() 역시 사용될 때 가장 첫 줄에 활용되어야 한다는 점이 this()와 동일하다. 그리고 extends를 이용해 상속을 받으면, 굳이 코드를 작성하지 않아도 컴파일러가 자동으로 super()를 가장 첫줄에서 선언하고 코드를 시작한다. super()는 부모 클래스의 생성자가 오버로딩된 방식을 그대로 따라 활용하면 된다.

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

class Circle extends Point{
    Circle(int radius){
        super(3,4);
        System.out.println("3,4를 중점으로 하고 반지름이 " + radius + "인 원을 그린다.");
    }
}

  Point 클래스를 보면, 생성자가 x와 y를 받도록 되어 있다. 만약 부모 클래스에 따로 생성자가 만들어져 있지 않다면 호출하지 않아도 컴파일러가 자동으로 호출해 주지만, 따로 만들어져 있다면 반드시 한 번 호출해야 한다. 생성자가 따로 있다는 것은 멤버 중 수동으로 초기화하는 멤버가 있다는 뜻이고, 수동으로 초기화해야 하는 멤버가 있다면 해당 클래스를 상속받는 자식 클래스에서도 수동으로 초기화를 해야 해당 멤버를 활용할 수 있기 때문이다.

접근 제어자, final

  • 접근 제어자
    접근 제어자는 public, default, protected, private으로 총 4가지가 있다.
    접근 제어자는 객체들 간 접근이 가능해야 하는 객체들과 그렇지 않은 객체들을 구분해 주는 역할을 한다. 이렇게 하는 이유는, 데이터를 외부로부터 보호하기 위함이다. 내부에서만 활용되어야 하는 데이터가 외부에서 접근이 가능하면 해킹의 위험이 커진다. 예를 들어보자
public class TestMyCode {
    public static void main(String[] args) {
        User user1 = new User();
        for (int i = 0; i < 6; i++) {
            System.out.println(LottoMachine.answerNumbers[i]);
        }
        int[] pickedNumbers = user1.pickNumbers();
        if(pickedNumbers.equals(LottoMachine.answerNumbers)){
            System.out.println("모두 맞히셨습니다!");
        } else {
        	System.out.println("모두 맞히지 못 하셨습니다")
        }
    }
}
class LottoMachine{
    static int[] answerNumbers = new int[]{1,2,3,4,5,6};
}

class User{
    int[] pickedNumbers = new int[6];
    int[] pickNumbers(){
        Scanner scanner = new Scanner(System.in);
        for(int i : pickedNumbers){
            i = scanner.nextByte();
        }
        return pickedNumbers;
    }
}

  이 코드를 그대로 실행해 보면, 콘솔에서 이미 답을 다 출력해 주는데, 그 방식이 클래스에 접근해서 정답숫자를 바로 출력하도록 하고 있기 때문이다. 원래대로라면 main에서는 로또 게임이 진행중이기 때문에 정답 숫자에 접근조차 할 수 없어야 하고, 그저 if문 안에서 맞췄는지, 맞추지 못 했는지만 알 수 있어야 한다. 따라서 LottoMachine의 멤버인 answerNumbers 앞에 private을 붙여, 타 클래스에서는 접근하지 못 하도록 막아 답을 미리 알 수 없게 해 주어야 한다.
  이렇게 접근 제어자를 통해 접근을 제한하는 과정을 '캡슐화'라고 한다.

제어자같은 클래스같은 패키지자손 클래스전체
publicOOOO               
protectedOOOX
defaultOOXX
privateOXXX
  • final
    final은 보통 어떤 대상에 붙느냐에 따라 붙은 대상을 변경 불가능하게 만들어 주는 역할을 한다.
    • 클래스 : 확장이 불가능한 클래스가 된다. 즉 상속을 할 수 없게 된다.
    • 메서드 : 변경이 불가능한 메서드가 된다. 즉 오버라이딩이 불가능하게 된다.
    • 멤버 변수/지역 변수 : 변경이 불가능한 값이 된다. 즉 상수가 된다.

  만약 클래스에서 멤버 변수 혹은 지역 변수를 final로 선언했다면, 생성자 단계에서부터 초기화해 주어야 하고, 인스턴스화가 된 이후에는 값이 변경이 불가능하기 때문에 새로 초기화해 줄 수 없게 된다. 따라서 생성자 단계에서 초기화를 하지 않으면 컴파일 에러가 난다.

다형성

  • 다형성
    다형성은 부모 클래스의 참조 변수로 자식 클래스를 다루는 것을 말한다.

많이 어려울 수 있는 개념이다. 일단 다형성이라는 말부터가 처음 들어볼 수 있다. 다형성의 사전적 정의를 먼저 알아보자. 아래는 'Oxford Language'에서 정의한 다형성의 정의이다.

다형성 : 동일종(同一種)의 생물이면서도 형태나 성질이 다양성을 보이는 상태. 암수에 의한 크기·형태·색깔 등의 차이와 꿀벌에서의 여왕벌과 일벌 같은 것.

  물론 꿀벌은 태어날 때부터 여왕벌과 일벌, 숫벌로 나뉘는 것이 아니라 태어나서 로얄 젤리를 얼마나 오래 먹었는지로 나뉜다고 하지만, 이 예시는 이해를 위한 것이므로 태어날 때 일벌 여왕벌 두 종류만이 정해지고 태어난다고 가정하자.
이 정의를 클래스에 적용해 보자. 동일종이라는 말은 조상이 같다는 것을 말한다. 즉 자식 클래스들 간에는 같은 부모를 조상으로 하고 있기 때문에 서로 동일종이다. 예시로 꿀벌, 여왕벌, 일벌을 각각 클래스로 고려한다면, 꿀벌이라는 부모 클래스를 여왕벌과 일벌 클래스는 상속받고 있을 것이다. 여기서 한 가지 추론이 가능한데, 꿀벌 클래스는 인스턴스화 되어서는 안 된다. 오롯이 꿀벌의 멤버만을 갖고 태어나는 꿀벌은 존재하지 않기 때문이다. 모든 꿀벌은 꿀벌의 특징과 더불어 일벌의 특징 혹은 여왕벌의 특징을 추가로 가진 채 태어난다. 이를 일반화해 표현하면

'꿀벌은 일벌의 형태 혹은 여왕벌의 형태로 태어난다.'

  따라서 인스턴스화 되어서는 안 되는 추상적 개념의 클래스가 필요하게 되는데, 이것이 바로 추상 클래스이고, 뒤에서 한 차례 더 등장하게 될 것이다. 다시 돌아와서, 그렇게 참조 변수의 타입은 꿀벌로 하고, 구체적 인스턴스는 일벌 혹은 여왕벌로 생성하게 되면, 멤버 자체는 꿀벌의 멤버를 따르지만, 오버라이딩된 메서드는 자식 클래스를 따라 호출된다. 이 문장이 이해가 조금 어려울 수 있는데, 아래 코드를 실행해 보면 확실하게 차이를 알 수 있다.

public class TestMyCode {
    public static void main(String[] args) {
        Book b = new Novel("소설","소설출판사");
        b.print();
        b.printUnOverrideMethond();

        Book c = new SF("메타버스", "SF출판사");
        c.print();
        c.printUnOverrideMethond();
    }
}
class Book{
    String name;
    String publisher;
    Book(){
        this.name = "";
        this.publisher = "";
    }
    Book(String name, String publisher){
        this.name = name;
        this.publisher = publisher;
    }
    void printUnOverrideMethond(){
        System.out.println("print : UnOverride");
    }
    void print(){
        System.out.println("print : Book");
    }
}

class Novel extends Book{
    Novel(String name, String publisher){
        super(name, publisher);
    }
    @Override
    void print(){
        System.out.println("print : Novel");
    }
}

class SF extends Book{
    SF(String name, String publisher){
        super(name, publisher);
    }
    @Override
    void print(){
        System.out.println("print : SF");
    }
}

  부모 클래스인 Book 타입의 b,c에 각각 Novel 인스턴스와 SF인스턴스를 생성했다. 그리고 자식 클래스는 모두 print()메서드를 오버라이딩했기 때문에 비록 부모 클래스인 Book 타입의 b와 c지만 자식 클래스의 print()를 각각 호출한다. 그리고 오버라이딩되지 않은 모든 멤버는 부모 클래스를 따라가기 때문에 printUnOverrideMethond() 역시 호출할 수 있다. 즉 자식들마다 각각의 특징적인 요소는 오버라이딩을 통해 변경하되, 공통적인 요소는 부모 클래스를 따라가는 것이다.

추상 클래스

  • 추상 클래스
    일반 클래스와 동일한데, 미완성된 추상 메서드를 포함하고 있는 클래스
    클래스를 설계도에 비유하곤 하는데, 미완성된 클래스는 곧 미완성된 설계도이고, 미완성된 설계도는 구체적으로 어떠한 기능을 가진 객체를 만들어낼 수 없다. 따라서 추상 메서드를 하나라도 가지고 있는 추상 클래스는 인스턴스화할 수 없다. 그 외에는 클래스와 모든 특징이 동일하다.

그럼 추상 클래스 왜 쓰는가?

  앞서 다형성에 대한 이야기를 했다. 꿀벌 예시를 다시 차용하자면, 꿀벌은 인스턴스화 되어서는 안 된다고 했다. 하지만 일반 클래스로 꿀벌 클래스를 생성하면 인스턴스화가 가능하다. 즉, 꿀벌이라는 개념은 말 그대로 추상적으로, 인간이 종을 구분하기 위해 만들어낸 개념일 뿐이다. 우리가 일벌을 가리키며 "꿀벌이다!"라고 하는 것이 틀리지 않은 이유는 일벌이 꿀벌의 특성을 모두 갖고 있기 때문이다. 여왕벌의 경우도 마찬가지다. 하지만 벌집 안에 일벌과 여왕벌이 모두 있을 경우, "일벌들이다!" 혹은 "여왕벌들이다!"라고 하면 틀린 것이 된다. 여기서 우리는 "꿀벌들이다!"라고 해야 한다. 일벌과 여왕벌은 모두 꿀벌의 특징을 공통적으로 갖고 있기 때문이다.
  이처럼 유사한 특징을 가졌지만 약간의 차이가 있는 객체들을 묶는 것을 '추상화'라고 하고, 따라서 클래스에 이 개념을 적용 했을 때 역시 일반 클래스가 아닌 추상 클래스로 선언해야 하는 것이다.

  한 줄 요약 : 인스턴스화하면 안 되는 클래스를 인스턴스화하지 않기 위해

인터페이스

  • 인터페이스
    인터페이스는 추상 클래스의 집합이다.
  • 인터페이스의 특징
    1. 모든 멤버가 public이다
    2. 상수 이외에 어떤 인스턴스 변수, 클래스 변수도 가질 수 없다
    3. 상수 선언시 타입 변수명만을 이용해 선언해도 앞에 public static final이 붙은 것이 된다.
    4. 메서드 선언시 반환 타입 메서드명만을 이용해 선언해도 앞에 public abstract가 붙은 것이 된다.
    5. 인터페이스의 조상은 인터페이스만 가능하다. - 따라서 최고 조상이 Object가 아니다.
    6. 상속 중 메서드가 충돌해도 어차피 자연스럽게 오버라이딩되서 충돌이 의미가 없다 - 따라서 다중 상속이 가능하다.

그럼 인터페이스는 왜 쓰는가?

중간 다리 역할이 필요할 때 인터페이스가 필요하다. 특정 인터페이스를 implements한 두 클래스가 있을 때, 만약 사용자로부터 어떠한 요청을 받았을 때, 로직을 수행하는 방식이 여러 가지일 수 있다. 이 경우 엔터페이스에 추상 메서드로 로직의 틀을 만들고, 클래스마다 다르게 구현해서 다양한 수행 방식의 경우의 수를 커버할 수 있다.

interface Repository {
    public int[] getData(); // 데이터를 가져오는 추상 메서드
}

class DatabaseA implements Repository {
    @Override
    public int[] getData() {
        System.out.println("Mysql 연결");
        return new int[]{55, 44, 33, 22, 11, 77, 88};
    }
}

class DatabaseB implements Repository {
    @Override
    public int[] getData() {
        System.out.println("Oracle 연결");
        return new int[]{1, 2, 3, 4, 5, 6, 7};
    }
}

class DatabaseAdmin {
    Repository repository = new DatabaseA();
//    Repository repository = new DatabaseB();

    public String getData() {
        return Arrays.toString(repository.getData());
    }
}

class User {
    DatabaseAdmin databaseAdmin = new DatabaseAdmin();
}

class DatabaseMain {
    public static void main(String[] args) {
        User user = new User();
        System.out.println(user.databaseAdmin.getData());

        // user 사용자가 갑자기 데이터 반환 방식 교체를 요구했습니다
        // 그런데 그 방식이 기존의 Database 로는 불가능 하다면?
        // 그래서 DB 교체가 필요해 졌습니다.
        // 이럴 경우 Repository repository = new DatabaseA();
        // 를 Repository repository = new DatabaseB(); 로 교체하면 됩니다.

        // User 자체는 전혀 수정되지 않기때문에 이런걸 느슨한 결합이라 부릅니다.
    }
}

위 예시에서 경우의 수는 A와 B데이터 베이스 중 어느 데이터 베이스에 연결할 것인가에 따른 두 가지인데, 로직 자체는 getData()로 하나다. 이 getData()를 처리하는 방식이 A와B 데이터 베이스 둘이 다른 것이다. 그리고 그 다르게 구현하는 것을 오버라이딩으로 구현하는 것이다.

New Issue

따로 정리하기에는 특정 목차에 들어가기 애매하지만 새로 알게 되었거나 중요한 내용들
1. static import
static 멤버를 사용할 때 클래스 이름을 생략할 수 있게 해 주는 선언

import static programmers.TestStaticImport.*;

public class TestMyCode {
    public static void main(String[] args) {
        System.out.println(number);
    }
}
class TestStaticImport{
    static int number = 5;
}
  1. 객체 instanceof 타입
    A instanceOf type - A의 타입이 type과 일치하는지 여부를 boolean으로 반환해줌. 객체의 실제 타입을 파악하는 데 활용되며, 부모 타입의 참조 변수에 자식 인스턴스를 넣을 경우 인스턴스의 타입을 따라간다.
profile
잘 읽어야 쓸 수 있고 잘 들어야 말할 수 있다

0개의 댓글