[JAVA] 다형성과 상속, 그리고 객체지향

kindof

·

2021. 6. 19. 23:04

1. 다형성과 상속에 대한 이해

Polymorphism in Java is the ability of an object to take many forms. To simply put, polymorphism in java allows us to perform the same action in many different ways.

다형성(Polymorphism)은 자바에서 한 객체가 여러 모습으로 기능할 수 있음을 의미합니다.

 

OOP에서 핵심적인 역할을 수행하는 녀석이 다형성이라고 볼 수 있죠.

 

그리고 오늘 공부할 상속(Inheritance)는 어떻게 보면 다형성을 달성할 수 있도록 도와주는 녀석이라고 생각합니다. 그리고 상속을 공부하면서 나오는 메서드 오버라이딩(Method Overriding)이 한 객체가 여러 기능을 다른 형태로 수행할 수 있도록 만들어주죠.

 

'재산을 상속받았다'라는 말처럼 상속(Inheritance)은 말 그대로 자식이 부모로부터 무언가를 물려받는 것을 말합니다. 자바에서도 상속이라는 개념은 '자식 클래스가 부모 클래스의 변수나 메서드 등의 속성을 상속받는 것'이라고 이해하면 편하겠습니다.

 

다만, 자식 클래스는 부모 클래스에 선언되어 있는 public, protected 변수와 메서드만을 상속받습니다. private 변수나 메서드는 상속이 불가능하죠. private은 애초에 다른 클래스에서 직접 접근을 못하도록 하려는 의도니까요.

 

private 변수, 메서드의 상속

 

* IS-A 관계

// Animal.java
public class Animal{
  String name;

  public void setName(String name){
    this.name = name;
  }
}

// Dog.java
public class Dog extends Animal{
  public void sleep(){
    System.out.println(this.name + " zzz");
  }

  public static void main(String[] args){
    Dog dog = new Dog();  // 객체 생성
    dog.setName("poppy"); // 부모 클래스의 메서드 사용
    System.out.println(dog.name);
  }
}

위 예시에서 Dog 클래스는 Animal 클래스를 상속받았었습니다. 이런 경우 "개는 동물이다"라고 표현할 수 있겠죠? 우리는 이러한 관계를 IS-A 관계라고 합니다.

 

한편, 이전 포스팅에서 업캐스팅(Upcasting)과 다운캐스팅(DownCasting)에 대해 공부하면서 상속 관계에 있는 클래스 간 캐스팅에 대해 정리했었습니다.

 

간단하게 다시 한 번 아래 예시를 통해 상속 관계에 있는 클래스 간 캐스팅에 대해 복습해보겠습니다.

Animal Dog = new Dog(); // O
Dog dog = new Animal(); // 컴파일 오류 : 부모 클래스로 만든 객체는 자식 클래스의 자료형으로 사용 불가

자식 클래스는 부모의 속성을 모두 가지고 있지만, 그 외에 속성이나 메서드를 추가할 수 있기 때문에 업캐스팅이 불가능합니다.

 

 

2. Object 클래스

자바에서 만드는 모든 클래스는 Object라는 클래스를 상속받게 되어 있습니다. 많은 이유 중에서 한 가지 이유는 모든 클래스에서 공통으로 포함하고 있어야 하는 기능을 제공하기 위해서입니다.

 

아래 사진에 있는 toString, equals, finalize 등의 메서드는 모든 클래스가 Object 클래스로부터 상속받아서 사용할 수 있는 메서드입니다. 그리고 이러한 메서드들은 개발자가 오버라이딩(Overiding)하여 클래스의 사용 용도에 최적화시킬 수 있죠.

Object

하지만, Object 클래스가 중요한 가장 큰 이유는 '모든 클래스를 품을 수 있는 Wrapper Class가 될 수 있기 때문'입니다.

 

아래 코드를 예로 들어보겠습니다.

 

아래 코드에서는 Join<>은 스프링에서 제공하는 인터페이스인데요. 컴파일 시점에 join의 결과로 어떤 타입의 객체가 생성되는지 예측하기 어려우니 Object를 사용하여 유연성을 확보합니다.

 

그리고 이와 반대로 기본 타입을 지정하여 다형성을 포기하고 타입 안정성과 속도를 우선시 한다면, 명시적인 제네릭을 사용하는 것이죠.

 

Object

결국, Object 클래스는 개발 관점에서 넓은 다형성을 제공해주는 장점이 있다는 것입니다. 하지만 아무데서나 Object를 남발하게 되면 해당 객체를 사용할 때는 형변환이 필요해지기 때문에 주의를 해야합니다.

 

 

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

다이나믹 메서드 디스패치란 쉽게 말해서 "Runtime에서 Overriding된 메서드 중 어떤 메서드를 실행시킬 것인가?" 를 의미합니다.

 

바로 아래 예시에서 Overriding된 toString()을 각 객체에 맞게 호출하는 것을 보면 이해하기 편할 것 같습니다.

class Parent{
    String name;

    Parent(String name){
        this.name = name;
    }
    public String toString() {
        return "parent name = " + name;
    }
}

class Child extends Parent{
    int age;
    Child(String name, int age){
        super(name);
        this.age = age;
    }
    public String toString(){
        return "child name = " + name + ", " + "age = " + age;
    }
}

public class test {
    public static void main(String[] args){
        Parent p = new Parent("james");
        System.out.println(p.toString());

        Child c = new Child("jo", 25);
        System.out.println(c.toString());
    }
}

따라서 우리가 자바 파일을 컴파일하여 실행시킬 때(Runtime), 메서드 이름만을 보고 어떤 메서드를 실행시킬 것인지 정하기 위해서는 호출한 객체가 어떤 클래스에 속한지를 보고 판단하는 것이죠.

 

이 과정이 바로 다이나믹 메서드 디스패치입니다. '아~그렇구나'하면 됩니다.

 

 

4. 추상 클래스

우선 추상 메서드(Abstract Method)에 대해서 이야기해보겠습니다. 추상 메서드는 이름에 'abstract'라는 식별자를 붙여놓고 내용은 안 써놓은 메서드입니다. 그리고 해당 메서드의 구현은 다른 클래스(Derived class)에게 맡깁니다.

 

그리고 추상 클래스(Abstract class)는 하나 이상의 추상 메서드를 가진 클래스를 말합니다. 마찬가지로 'abstract'라는 식별자가 클래스 이름 앞에 붙죠.

 

추상 클래스들은 'new' 키워드를 통해서 초기화될 수 없는데, 당연하게 생각해보면 아직 그 클래스에서 선언한 메서드가 정의되지 않았기 때문입니다. 미완성인 메서드가 있으니까 그 객체는 생성이 불가능하죠.

 

그렇다면 추상 클래스는 왜 필요할까요? '추상'이라는 말이 전하는 느낌처럼 메서드들을 "일단 정의해놓고" 클래스들 간의 계층(Hierarchy)를 쉽게 파악하기 위해서로 생각하면 될 것 같습니다. 라고 이전에 제가 설명했었는데 시간이 지나고 보니 더 근본적인 이유가 있는 것 같습니다.

 

우선 추상 클래스와 인터페이스의 차이에 대해 고민해봐야 합니다. 추상 클래스와 인터페이스는 공통적으로 객체를 추상화하여 객체 간의 의존성을 최소화하기 위해 사용된다고 생각하는데요. 쉽게 말해서 어떤 객체가 다른 객체에 직접적으로 의존하면, 객체가 수정되는 순간 그 객체에게 의존하던 객체도 의존성을 바꿔야한다는거죠.

 

그리고 객체가 이러한 추상화에 의존할 때, 객체가 바뀌는 것에 대한 영향이 줄어들게 됩니다.

 

하지만, 추상 클래스와 인터페이스에는 차이가 있습니다. 추상 클래스도 '하나의 클래스'라는 데서 그 차이점들이 생기죠.

  1. 추상 클래스는 일반 변수 선언이 가능하지만 인터페이스는 상수(static final)만 가능합니다.
  2. 추상 클래스는 일반 메서드도 동시에 가지고 있을 수 있습니다. 인터페이스는 일반 메서드가 없죠. 공부를 하다 보니, 자바 8버전부터는 인터페이스도 Default Method를 갖을 수 있게 되면서 이 차이점은 의미있는 것 같지 않습니다.
  3. 추상 클래스도 클래스이기 때문에 다중상속이 불가능하지만 인터페이스는 다중상속이 가능합니다.

 

아래는 추상 클래스에 대한 간단한 예제입니다.

abstract class Unit {
    int x, y;
    abstract void move(int x, int y); 
    void stop() { ... } // stop at current position
}
class Marine extends Unit {
    void move(int x, int y) { ... }
    void stimPack() { ... } // using stimPack
}
class Tank extends Unit {
    void move(int x, int y) { ... }
    void changeMode() { } // change attack mode
}
class Dropship extends Unit {
    void move(int x, int y) { ... }
    void load() { ... } // load the selected object
    void unload() { ... } // unload the selected object
}

위 코드와 계층 그림처럼 Unit이라는 추상 클래스를 정의해놓고, 자식 클래스들은 Unit을 클래스를 상속받습니다.

 

그리고 상속을 받은 이후에는 부모가 가지고 있던 추상 메서드들을 오버라이딩해서 정의하고 사용할 수 있게 됩니다.

 

5. final 키워드

final의 사전적 의미를 찾아보면 "최종적인, 최후의, 마지막의"라는 뜻입니다.

 

사전적 정의를 비슷하게 살려보면, final 키워드는 엔티티를 한 번만 할당하게 하는, 즉, 두 번 이상 할당하려 할 때 컴파일 오류가 발생하게 하는 역할을 합니다.

 

다시 말해서,

  1. final이 선언된 메소드는 Overriding해서 구현할 수 없다.
  2. fianl이 선언된 상수는 Read-Only로 설정된다. 즉 한 번 선언한 뒤 변하지 않는 Immutable 형식이라는 것을 명시적으로 표현할 수 있다.

라고 이해할 수 있습니다.

 

아래 예시를 보겠습니다.

class Parent{
    public final void print(){
        System.out.println("parent 클래스의 print() 메서드입니다.");
    }
}

class Child extends Parent{
    int age;
    void print(){   // 부모 클래스에서 final 메서드로 정의되었기 때문에 Overriding X
        System.out.println("child 클래스의 print() 메서드입니다.");
    }
}

부모 클래스에서 final 메서드를 만들면 상속받은 클래스에서는 오버라이딩이 불가능합니다.

 

또한 애초에 위 예시에서 Parent class를 final class Parent로 바꿔버리면, 상속 자체를 할 수가 없게 됩니다.

 

특히 final 키워드는 클래스 및 생성자 의존성을 고려할 때 중요해지는데, 생성자 주입을 통해 의존관계 설정을 할 때, final 키워드는 핵심적인 기능을 하게 됩니다.

 

*static 키워드와 final

static은 해당 데이터의 메모리 할당을 컴파일 시간에 할 것임을 의미합니다. final 키워드를 사용하는 이유와 비슷한 맥락을 한다고 볼 수 있죠.

 

만약 클래스에서 사용할 해당 멤버 변수의 데이터의 의미와 용도를 고정시키고 싶다고 가정해보겠습니다. 예를 들어 성적 클래스에서 '과목 최대 점수'라는 변수를 만든다면 100이 되겠죠.

 

이 값은 모든 클래스 인스턴스(예를 들어 국어, 영어, 수학 등 과목을 상속하는..)에서 똑같이 써야 할 겁니다. 그래서 우리는 아래와 같이 변하지 않고 한 번만 할당되도록 static final 키워드를 쓸 수 있습니다.

 

조금 더 정확히 말해서 static final은 다른 사람으로하여금 객체들이 공유하는 변수를 변경하는 행위를 금지하겠다는 강제성을 가지고 있다고 보는게 맞는 것 같습니다.

public static final int MAX_SCORE = 100;

 


 

정신없이 많은 내용을 다뤘는데, 하나하나 정말 중요한 내용인 것 같습니다.

 

글이 너무 길어져서 많은 설명과 예시를 담지 못했는데 궁금한 부분이 있으시면 댓글 달아주시면 감사하겠습니다.