본문 바로가기

프로그래밍 언어 기초/KOTLIN

[Kotlin] 객체지향 프로그래밍 - 상속, this, super, 오버라이딩, 오버로딩

개요

오늘은 지난 포스팅에 이어서 객체지향 프로그래밍에 대해 더 자세히 알아보겠습니다.

객체지향 프로그래밍에서 프로그램 전체의 흐름을 결정하고 주관하는 것은 `객체`입니다.

객체지향 프로그래밍에서 프로그램을 구성하는 `객체(Object)`는 매우 중요한 역할을 담당합니다.

특히 이 `객체(Object)`들은 현실 세계에서 사람이 상호 간에 관계를 맺고 살아가는 것처럼

프로그램 내에서도 상호 관계를 맺어서 상호 작용하며 아무리 복잡한 작업이라도 수행할 수 있도록 임무를 수행합니다.

그러므로 오늘은 객체지향 프로그래밍에서 어떻게 객체(Object)가 

서로 관계를 맺고 상호작용하는지 그 방법론에 대해 알아보도록 하겠습니다.


코틀린의 상속

코틀린에서 객체는 class 키워드를 통해 아래와 같이 선언할 수 있습니다.

class MyClass;


그리고 이 클래스는 상호 간에 어떤 관계나 역할을 갖도록 설계할 수 있습니다.

마치 현실 세계에서 부모와 자식, 형제와 자매와 같은 관계의 설정이 가능하다는 의미입니다.
그렇다면, 코틀린과 같은 객체지향 언어에서는 어떻게 클래스 사이의 관계(Relationship)는 어떻게 설정할까요?

 

이때 사용할 수 있는 기술이 바로 상속(Inheritance) 입니다.

코틀린을 비롯한 대부분의 객체지향 프로그래밍 언어에서는 객체 간의 관계를 형성할 수 있도록 상속(Inheritance)을 지원합니다.

예컨대 다음과 같은 코틀린 클래스 A와 B가 있다고 하겠습니다.

class A {
    val aValue = 1;
}

class B {
    val bValue = 2;
}

 

 

이때, 코틀린 개발자는 클래스 B는 클래스 A를 상속받도록 하여 A와 B가 부모-자식의 관계를 맺도록 해야 한다고 합니다.

코틀린에서는 콜론(:)을 사용해 아래와 같이 클래스를 상속받게 할 수 있습니다.

class A {
    val aValue = 1;
}

class B : A {
    val bValue = 2;
}

 

그런데, 여기까지 작성하고 코틀린 파일을 실행해보면 아래와 같은 오류를 마주합니다.

This type is final, so it cannot be inherited from



코틀린에서는 기본적으로 클래스 간에 상속이 되지 않도록 해두었습니다.

따라서, 클래스 간의 상속을 허용하게 하려면 open 키워드를 사용해야 합니다.

이를 반영하여 아래와 같이 수정해보겠습니다.

open class A {
    val aValue = 1;
}

class B : A {
    val bValue = 2;
}

 

 

여기까지 작성하고 다시 한번 실행해보면 또 아래와 같은 오류를 마주합니다.

This type has a constructor, and thus must be initialized here

 


코틀린에서 클래스 간에 부모-자식 관계를 맺어 상속하려면,

자식 클래스에서 부모 클래스의 생성자를 반드시 1회 이상 호출해야 합니다.

 

이는 부모 클래스의 생성자를 통해 부모 클래스를 초기화해야 하기 때문입니다.

따라서 아래와 같이 작성하여 클래스 상속과 함께

부모 클래스의 주 생성자를 호출하여 오류를 발생시키지 않도록 할 수 있습니다.

class B : A() {
    val bValue = 2;
}


위 코드는 클래스 B가 A를 상속받으면서 동시에 클래스 A의 생성자를 호출함을 의미합니다.

또는, 아래와 같이 보조 생성자에서 super() 키워드로 생성자를 호출할 수도 있습니다.

class B : A {
    val bValue = 2;
    constructor():super(){
    }
}


만약 자식 클래스에서 보조 생성자가 1개 이상이라면 그중 한 곳 이상에서만

부모 클래스의 `생성자`를 호출하면 됩니다.

class B : A() {
    val bValue = 2;
    constructor(a:Int):super(){
    }
    constructor(){
    }
}

 

추가로 아래와 같은 문법도 가능합니다.

class B : A() {
    val bValue = 2;
    constructor(a:Int):super(){
    }
    constructor():super(){
    }
}

 

여기서 주의할 점은 위 두 가지 방법은 서로 혼용이 되지 않는다는 점입니다.

아래의 코드를 살펴보겠습니다.

 

open class A {
    val aValue = 1;
}

class B : A() {
    val bValue = 2;
    constructor(a:Int){
    }
    constructor(){
    }
}


위 코드에서 자식 클래스 B는 부모 클래스 A의 생성자를 콜론(:) 뒤에서 호출하고 있습니다.

그리고 보조 생성자를 몇 개 더 선언하였는데,

이 경우에 눈으로 보기에는 클래스 이름 부분에서 생성자를 호출해서 별문제가 없으리라 예상할 수 있습니다.

그러나, 코틀린에서는 위와 같은 문법을 지원하지 않으므로

보조 생성자를 모두 없애고 상속 부분에서만 생성자를 호출하거나,

상속 부분에 생성자 호출을 제거하고, 보조 생성자에서만 super() 키워드로 호출하여야 합니다.

// 보조 생성자를 모두 없애고 상속 부분에서만 생성자를 호출
class B : A() {
    val bValue = 2;
}

 

 

// 상속 부분에 생성자 호출을 제거하고, 보조 생성자에서만 super() 키워드로 호출
class B : A {
    val bValue = 2;
    constructor():super(){
    }
}

 

이제 메인 함수에서 클래스 B의 인스턴스를 생성해 보도록 하겠습니다.

 

fun main() {

    val clsB = B();
    println(clsB.aValue); // 1
    println(clsB.bValue); // 2

}



위 코드를 보면 클래스 B는 자신의 프로퍼티로 aValue를 선언한 적이 없습니다.

하지만, 클래스 A를 상속받았기 때문에 클래스 A의 프로퍼티인 aValue도 사용할 수 있습니다.


this, super

코틀린 클래스는 상속을 지원하기 때문에,

클래스마다 자기 자신의 고유한 프로퍼티인지 아니면 상속받은 부모 클래스의 프로퍼티인지 구분할 필요가 있습니다.

이때 사용할 수 있는 키워드가 this와 super입니다.

super는 앞선 예시 코드에서 부모 클래스의 생성자를 호출하는 데 사용했습니다.

이처럼 클래스에서 super는 부모/자식 관계가 있을 때, 

부모 클래스의 어떤 프로퍼티나 함수를 참조하려고 할 때 구체적으로 가리키는 방향키로 사용할 수 있습니다.

open class A {
    val aValue = 1;
}

class B : A {
    var bValue = 2;
    constructor(a:Int){
       this.bValue = super.aValue + a;
    }
}

 

위 코드에서 클래스 B는 생성자에서 매개변수와 부모 클래스의 aValue 값을 더해서

자신의 프로퍼티 bValue에 값을 바꿔주었습니다.


이번에는 조금 더 복잡한 예시를 살펴보겠습니다.

 

open class A {
    var aValue = 1;
}

class B : A {
    var bValue = 2;
    constructor(a:Int){
        this.aValue = 2 * a;
        super.aValue = a;
        println(this.aValue == super.aValue)
        println(this.aValue === super.aValue)
        println("super.aValue : ${super.aValue}, this.aValue : ${this.aValue}")
    }
}


위 코드에서 클래스 B는 생성자에서 super와 this 키워드를 사용하여 프로퍼티 aValue를 가리키고 있습니다.

 

this가 자기 자신을 가리키고, super가 부모를 가리키는 것이라면 이 결과는 서로 다르게 나올까요?

메인 함수에서 클래스 B의 인스턴스를 생성하여 결과를 확인해 보겠습니다.

fun main() {
    val clsB = B(2);
    /**
     * true
     * true
     * super.aValue : 2, this.aValue : 2
     */
}


결과는 두 값 모두 같은 값을 출력하는 것을 확인할 수 있습니다.

그렇다면 생성자에서 코드의 순서를 바꾸면 결과가 달라질까요?

이번에는 아래와 같이 생성자를 구성하고 메인 함수에서 인스턴스를 생성해보겠습니다.

open class A {
    var aValue = 1;
}

class B : A {
    var bValue = 2;
    constructor(a:Int){
        super.aValue = a;
        this.aValue = 2 * a;
        println(this.aValue == super.aValue)
        println(this.aValue === super.aValue)
        println("super.aValue : ${super.aValue}, this.aValue : ${this.aValue}")
    }
}

fun main() {
    val clsB = B(2);
    /**
     * true
     * true
     * super.aValue : 4, this.aValue : 4
     */
}

 

이번에는 this 키워드로 계산한 값이 최종 반영되어 두 값 모두 같은 값을 출력했습니다.

이를 통해 코틀린에서는 부모 클래스에서 상속받은 프로퍼티는

기본적으로 super든지 this든지 같은 값을 참조한다는 것을 알 수 있었습니다.


변수의 재정의

그런데, 이렇게 부모 클래스로부터 상속받은 프로퍼티가 서로 같은 값을 참조한다면,

때에 따라서는 개발자가 원하는 대로 바꾸어 쓰는데 많은 제약이 발생할 수 있습니다.

그러면 코틀린은 부모 클래스의 프로퍼티와 자식 클래스의 프로퍼티를 구분할 방법이 없는 것일까요?

이 경우 코틀린에서는 변수의 재정의(Overrding)를 통해 두 값을 구분해낼 수 있습니다.

아래의 코드를 살펴보겠습니다.

open class A {
    open var aValue = 1;
}

class B : A {
    var bValue = 2;
    override var aValue = 1;
    constructor(a:Int){
        super.aValue = a;
        this.aValue = 2 * a;
        println(this.aValue == super.aValue)
        println(this.aValue === super.aValue)
        println("super.aValue : ${super.aValue}, this.aValue : ${this.aValue}")
    }
}


위 코드에서 클래스 A는 똑같이 프로퍼티 aValue를 가지고 있습니다.

그리고 클래스 B에서는 override라는 키워드를 통해 부모 클래스에 있는 프로퍼티를 다시 작성하고 있습니다.

이때 아래와 같이 메인 함수에서 인스턴스를 생성하면 어떻게 될까요?

fun main() {
    val clsB = B(2);
    /**
     * false
     * false
     * super.aValue : 2, this.aValue : 4
     */
}


드디어 두 값을 서로 구분할 수 있게 되었습니다.

위와 같이 코틀린에서는 객체 간에 부모-자식 관계에 있을 때, 부모 클래스에서 어떤 변수에 재정의(override)를 허용하면
자식 클래스에서 재정의하여 서로 다른 값으로 구분할 수 있음을 알 수 있었습니다.

그리고 이 재정의를 위해서는 부모 클래스에서 해당 변수에 open 키워드를 붙여 주어야 합니다.


오버라이딩(overriding)과 오버로딩(overloading)

객체지향 프로그래밍에는 오버라이딩(overriding)오버로딩(overloading)이라는 중요한 개념이 있습니다.

두 개념은 객체지향 프로그래밍에서 다형성(polymorphism)을 실현하기 위한 중요 기술입니다.

여기서 다형성(polymorphism)이란, 객체지향 프로그래밍의 중요한 특징 중 하나로,

같은 코드가 다양한 형태로 동작할 수 있는 능력을 의미하고, 결과적으로 코드의 유연성과 재사용성을 높여줍니다.

하지만, 오버라이딩과 오버로딩은 서로 구분되는 개념입니다.

우선 오버라이딩은 객체지향 프로그래밍에서 두 클래스가 서로 상속의 관계를 맺었을 때

자식 클래스에서 부모 클래스에서 이미 정의한 함수 등에 대해 새롭게 정의하여 사용할 수 있는 기술을 의미합니다.

이에 따라 자식 클래스는 부모 클래스를 상속받고 있더라도 부모 클래스와 다른 형태로 동작할 수 있습니다.

반면에 오버로딩은 클래스 내에서 같은 이름의 함수가 여러 개 존재할 수 있는 기술입니다.

어떤 두 함수가 매개변수의 타입, 개수, 순서 등에서 서로 구분 가능하다면

각각의 함수는 이러한 함수의 특징, 즉 함수 시그니처(Function Signature)를 통해

식별할 수 있으므로 클래스 내부에 같은 이름을 가진 함수가 여러 개 만들 수 있습니다.

 

그리고 이렇게 이름이 같은 함수가 여러 개 존재함으로 인해 클래스가 다양하게 동작할 수 있도록 하는 기술이 오버로딩 입니다.


오버라이딩(overriding)

앞선 설명에서 오버라이딩은 두 클래스가 상속 관계에 있을 때,

자식 클래스에서 부모 클래스의 함수 등을 재정의할 수 있는 것이라고 설명했습니다.

이를 더 자세히 분석하기 위해 아래의 코드를 살펴보도록 하겠습니다.

 

open class Parent{
    open fun greet() {
        println("Hello world!");
    }
}

class Child : Parent() {
    override fun greet(){
        println("Hello Kotlin!");
    }
}


클래스 Parent에서는 메서드 greet()이 구현되어 있습니다.

그런데, Parent 클래스를 상속받는 Child 클래스에서 greet() 메서드를 override 키워드를 통해 재정의하였습니다.

 

이 경우에 Child 클래스의 인스턴스를 생성하고 greet() 함수를 호출하면 어떻게 될까요?

메인 함수에서 아래와 같이 인스턴스를 생성하고 greet() 함수를 호출해보겠습니다.

fun main() {
    val child = Child()
    child.greet() // Hello Kotlin!
}


결과는 Hello Kotlin!이 출력되었습니다.

이처럼 어떤 함수 등을 오버라이딩하게 되면 부모 클래스에서 먼저 정의되었던 

메서드는 무시되고 자식 클래스에서 새롭게 정의된 메서드가 실행됩니다.

 

그리고 이러한 규칙은 이미 작성된 함수를 개발자가 재정의할 때

자연스럽게 새롭게 정의한 대로 동작하여 결과적으로 프로그램의 동작을 쉽게 예측할 수 있도록 해줍니다.

 

즉, 개발자가 이미 구현된 클래스를 가져다가 상속을 통해 활용할 때,
개발하려는 프로그램 목적에 맞게 자유롭고, 안전하게 프로그램을 설계할 수 있도록 한다는 것입니다.

 


오버로딩(overloading)

이어서 오버로딩에 대해 살펴보도록 하겠습니다.

오버로딩은 클래스 내에서 같은 이름의 함수가 여러 개 존재할 수 있는 기술이라고 설명했습니다.

 

그리고 하나의 클래스 안에서 같은 이름의 함수가 여러 개 존재할 수 있는 이유는 

함수의 이름, 변수의 개수, 타입 등과 같은 함수 자체의 고유한 특징 

즉, 함수 시그니처(Function Signature)를 기준으로  함수를 구분할 수 있기 때문입니다.

 

class Printer{
    fun print(){
        println("simple print");
    }

    fun print(a:Int){
        println("simple print $a");
    }

    fun print(a:Int, b:Boolean){
        println("simple print $a $b");
    }

    fun print(a:Int, b:Float){
        println("simple print $a $b");
    }
}



클래스 Printer 에서는 print라는 이름을 가진 함수가 4개 존재합니다.

이때 각 함수는 이름은 같지만, 함수에 들어오는 파라미터의 개수와 종류 서로 달라 구분할 수 있습니다.

fun main() {
    val printer = Printer();
    printer.print(); // simple print
    printer.print(1); // simple print 1
    printer.print(1, false); // simple print 1 false
    printer.print(1, 1.5f); // simple print 1 1.5
}

 

따라서 위와 같이 클래스 Printer의 인스턴스를 생성하여 메서드를 실행하면,

각각의 메서드에 들어가는 파라미터를 기준으로 메서드를 구분하여 정상적으로 실행됩니다.