인프런/자바 개발자를 위한 코틀린 입

5) 코틀린에서 상속을 다루는 방법

backend dev 2025. 1. 8.

1. 추상 클래스

 

 

예시) Animal이란 추상클래스를 구현한 Cat, Penguin

 

 

 

자바에서 Animal

public abstract class JavaAnimal {

  protected final String species;
  protected final int legCount;

  public JavaAnimal(String species, int legCount) {
    this.species = species;
    this.legCount = legCount;
  }

  abstract public void move();

  public String getSpecies() {
    return species;
  }

  public int getLegCount() {
    return legCount;
  }

}

 

코틀린으로 변환시

abstract class Animal(
    protected val species:String,
    protected val legCount:Int
    ) {
    abstract fun move()
}

 

 

자바에서 Cat

public class JavaCat extends JavaAnimal {

  public JavaCat(String species) {
    super(species, 4);
  }

  @Override
  public void move() {
    System.out.println("고양이가 사뿐 사뿐 걸어가~");
  }

}

 

코틀린으로 변환시

class Cat(
    species: String // 타입을 적을때는 콜론을 한칸뛰지않는것이 convention
) : Animal(species,4) { // 상속할때는 : 콜론을 한칸뛰는것이 convention
// 괄호, 자식클래스의 주 생성자 매개변수를 통해 부모 클래스의 생성자를 바로 사용가능하다.

    override fun move() {
        println("고양이가 사뿐")
    }

}

 

: 를 이용하여 상속을 진행한다. 

자식 클래스 객체를 생성할때 부모 클래스 객체도 생성해줘야한다. 
Animal 뒤의 괄호를 이용하여 부모 클래스의 생성자를 호출할 수 있다.

[

  • 자식 클래스의 객체를 생성할 때, 부모 클래스의 객체도 메모리에 할당됩니다.
  • 자식 클래스는 부모 클래스의 상태(state)를 공유하거나 상속받은 기능을 사용할 수 있어야 하므로, 부모 클래스의 메모리 공간이 먼저 할당됩니다
  • 자식 클래스의 생성자가 호출될 때, 자식 클래스의 생성자가 부모 클래스의 생성자를 호출합니다.
  • 부모 클래스의 생성자는 자식 클래스가 메모리 상에 올라오기 전에 부모 클래스의 인스턴스를 생성하고 초기화해야 합니다.
  • 자식 클래스의 생성자에서 명시적으로 부모 클래스의 생성자를 호출하지 않으면, 컴파일러가 자동으로 super()를 호출하여 부모 클래스의 기본 생성자를 실행합니다.
  • super() 호출은 부모 클래스의 생성자를 통해 부모 클래스의 필드가 초기화되고, 부모 클래스의 객체가 생성되는 역할을 합니다.

]

 

 

코틀린에서는 오버라이드할때 어노테이션이 아닌 override는 지시어(keyword)를 사용한다. 

 

 

 

펭귄 구현 자바 예시코드)

public final class JavaPenguin extends JavaAnimal {

  private final int wingCount;

  public JavaPenguin(String species) {
    super(species, 2);
    this.wingCount = 2;
  }

  @Override
  public void move() {
    System.out.println("펭귄이 움직입니다~ 꿱꿱");
  }

  @Override
  public int getLegCount() {
    return super.legCount + this.wingCount;
  }

}

 

코틀린으로 변환시

class Penguin(
    private val wingCount: Int = 2,
    species: String
) : Animal(species,2){

    override fun move() {
        println("penguin move")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount

}

 

@Override
public int getLegCount() {
  return super.legCount + this.wingCount;
}

이 자바코드가

override val legCount: Int
    get() = super.legCount + this.wingCount
}

코틀린에서는 이렇게 변경된다.

 

getLegCount()라는 getter를 오버라이드해야하고

코틀린에서는 프로퍼티를 가져오려고하면 getter 프로퍼티에 값을 쓰려하면 setter가 동작하기 때문에

 

부모클래스(Animal)에 있는 legCount라는 프로퍼티를 오버라이드해서 getter를 재정의해줘야한다 그래서 위와 같은 코드가 나온것이다.

abstract class Animal(
    protected val species:String,
    protected open val legCount:Int
    ) {
    abstract fun move()
}

이때 부모클래스에서는 프로퍼티앞에 open을 붙여줘야 해당 프로퍼티의 오버라이드가 가능하다.

 


2. 인터페이스

예시)

Flyable과 Swimmable을 구현한 Penguin

 

 

자바코드 인터페이스)

public interface JavaSwimable {

  default void act() {
    System.out.println("어푸 어푸");
  }

}
public interface JavaFlyable {

  default void act() {
    System.out.println("파닥 파닥");
  }

}

 

default 메소드는 기본 구현을 제공하고, 구현을 강제하지 않는 메소드에 붙여주면 된다.

 

public final class JavaPenguin extends JavaAnimal implements JavaSwimable, JavaFlyable {

  private final int wingCount;

  public JavaPenguin(String species) {
    super(species, 2);
    this.wingCount = 2;
  }

  @Override
  public void move() {
    System.out.println("펭귄이 움직입니다~ 꿱꿱");
  }

  @Override
  public int getLegCount() {
    return super.legCount + this.wingCount;
  }

  @Override
  public void act() {
    JavaSwimable.super.act();
    JavaFlyable.super.act();

  }
  
}
JavaSwimable.super.act();

이런식으로 코드가 되어있는 이유

-> JavaSwimable,JavaFlyable 둘다 act() 라는 default 메소드가 존재한다.

그러므로 this.act()로 사용 불가능

 

JavaSwimable.super를 통해 해당 클래스에 접근해서 .act()로 default 메소드를 실행한다.

 

JavaSwimable.act();

처럼 실행할 수 없는 이유는 해당 코드는 static 메소드를 저런식으로 호출하기때문이다.

 

act()는 default 메소드로 인터페이스 또는 인스턴스를 통해 호출되어야한다. 

 

그래서 JavaSimable.super로 해당 인터페이스에 접근하고 .act()를 통해 default 메소드를 실행한다.

 

super는 부모 클래스,부모 인터페이스에 접근하기 위한 keywords로 생각하면된다.

 

위의 코드는 중복되는 메소드를 가진 인터페이스 2개를 구현하므로 인터페이스.super.메소드() 식으로 사용한것이다.

 

코틀린으로 변환

class Penguin(
    private val wingCount: Int = 2,
    species: String
) : Animal(species,2), Swimable,Flyable{

    override fun move() {
        println("penguin move")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount

    override fun act() {
        super<Swimable>.act()
        super<Flyable>.act()
    }
}

 

: 를 통해 인터페이스 구현을 표현한다. 

 

중복되는 인터페이스를 특정할때 super<타입>.함수를 사용한다.

 

코틀린 또한 인터페이스 자체를 인스턴스화 할 수 없다.

val swim: Swimable = Swimable() // X
val swim2: Swimable = Penguin(species = "황제펭귄") // O

인터페이스 자체로 객체 생성 불가하지만

인터페이스 변수에 해당 인터페이스 구현 객체 담을 수 있는것도 같다.

 

Kotlin에서는 backing field가 없는 프로퍼티를 Interface에 만들 수 있다

 

 

프로퍼티는 field + getter + setter로 구성되어있다.

여기서 field는 프로퍼티의 값을 담는 변수라고 생각하면 된다.

그리고 이 field는 보이지않는다라고해서 backing field라고 불린다. field라는 예약어(keyword)를 통해 호출할 수 있다.

 

var로 선언하든 val로 선언하든 프로퍼티는 backing field == field 를 자동으로 가진다.

 

인터페이스에 정의된 프로퍼티는 backing field를 자동으로 가지지 않는다.

인터페이스에 정의된 프로퍼티를 사용하려면 구현체 클래스에서 해당 프로퍼티의 get()를 구현해주면 된다.

해당 프로퍼티는 backing field가 없으므로 set()를 구현해주고 싶다면 그 로직안에 다른 프로퍼티에 저장되게끔 구현하면 된다.

 

여기서 말하는 backing field가 없다는것은 "값"이 없다는것을 의미하고

값을 저장할 field가 없다는것은 아니다.

 

코틀린은 인터페이스에서 backing field(=값)이 없는 프로퍼티를 정의할 수 있고,

값이 없기때문에 get() 정도만 인터페이스에서 정의할 수 있다.

set()은 구현체에서 정의하면 된다. 

 

 

예시코드)

interface Swimable {

    val swimAbility: Int

    fun act(){
        println("어푸")
    }
}
class Penguin(
    private val wingCount: Int = 2,
    species: String
) : Animal(species,2), Swimable,Flyable{

    override fun move() {
        println("penguin move")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount

    override fun act() {
        super<Swimable>.act()
        super<Flyable>.act()
    }

    override val swimAbility: Int
        get() = 3

}

혹은

interface Swimable {

    val swimAbility: Int
        get() = 3

    fun act(){
        println("어푸")
        println(swimAbility)
    }
}
class Penguin(
    private val wingCount: Int = 2,
    species: String
) : Animal(species,2), Swimable,Flyable{

    override fun move() {
        println("penguin move")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount

    override fun act() {
        super<Swimable>.act()
        super<Flyable>.act()
        println(swimAbility) 
    }

}

 

setter를 구현한 다른 예시)

interface Swimable {
    var swimAbility: Int // var로 선언되면 setter를 구현해야 함
}

class Fish : Swimable {
    override var swimAbility: Int = 3  // getter와 setter 구현
        get() = field
        set(value) {
            field = value
        }
}

fun main() {
    val fish = Fish()
    fish.swimAbility = 5  // setter를 통해 값을 설정
    println(fish.swimAbility)  // 5 출력
}

 


 

3. 클래스를 상속할 때 주의할 점

- 상속받을 클래스는 open으로 열어줘야한다.

- 오버라이드 될 프로퍼티는 open으로 열어줘야한다.

 

상속 주의 예시)

fun main() {
    Derived(300)
}


open class Base( // 해당 클래스를 상속할 수 있게 open으로 설정
    open val number: Int = 100 // 해당 프로퍼티를 상속할 수 있게 open으로 설정
){
    init {
        println("Base Class")
        println(number)
    }
}

class Derived(
    override val number: Int
) : Base(number) {

    init {
        println("Derived Class")
    }
}

 

Derived 객체를 생성하게되면 Base(number)가 실행되면서 init 블록또한 실행되므로

"Base Class"가 출력되고 난 뒤에  number값이 출력되고 난뒤 하위 클래스의 init 블록이 실행된 모습이다.

 

여기서 number의 출력이 0인 이유는 무엇일까

 

실행순서

Derived(300)이 실행되면서 Derived 클래스의 객체를 만들기 위해

Derived(
    override val number: Int
)

이 Derived 클래스의 주 생성자가 실행될것이다.

 

하지만 주 생성자가 실행되기전에 상위 클래스의 객체를 생성해야하므로

Base(number)

Base 클래스의 생성자가 호출이 된다.  그때 전달한 전달하는 number는 300이다

 

그때 Derived의 number 프로퍼티에는 초기값 0을 가지고있다.

그 이유는 아직 자식클래스인 Dereived는 부모클래스가 생성되고 난뒤에

Dereived 객체가 생성되면서 프로퍼티 초기화가 진행되기 때문이다.

open class Base( 
    open val number: Int = 100
){
    init {
        println("Base Class")
        println(number)
    }
}

그러면 생성된 Base 객체의 number 프로퍼티의 필드는 300으로 채워지면서 생성이 된후

 

init 블록이 실행되는데

그때 println(number)의 가 실행되면 number의 값을 가져오기위해 number 프로퍼티의 getter가 실행된다.

Derived클래스에서 Base클래스의 number 프로퍼티를 재정의 했기 때문에 Derived의 number 프로퍼티의 getter가 실행되고,

현재 Derived 프로퍼티들은 초기화가 되어있지않기 때문에 초기값인 0이 반환되어 출력되는것이다. 

 

 

인텔리제이는 println(number) 부분에

Accessing non-final property number in constructor 라는 컴파일 에러를 나타내주고있다.

 

실행순서 이해 예시)

open class Base(
    open val num: Int = 100
) {
    init {
        println("Base init")
        println(this.num)
    }
}

class Derived(
    num: Int
) : Base(num) {
    override val num: Int = num
        get() {
            println("Derived getter")
            return field
        }
    init {
        println("Derived init")
        println(this.num)
    }
}

fun main() {
    Derived(300)
}

------- Result ------
Base init
Derived getter
0
Derived init
Derived getter
300


--------------------
1. Derived(300) 실행
2. Derived 주 생성자 실행 
	2.1 Derived 주 생성자 실행전 Base(num) 실행 [ Base 클래스 주 생성자 실행 ], num에는 300 전달
    2.2 Base클래스의 num에는 300 저장 및 객체 생성완료
    2.3 객체생성이 완료됬으니 init 블럭 실행 
    2.4 init 블럭에서 println(this.num) 실행시 this.num은 Base의 num 프로퍼티를 의미하지만 
    	Base의 num 프로퍼티를 Derived가 Override 했으므로 Derived의 num 프로퍼티에 접근한다. == Derived의 num의 getter를 호출한다.
        하지만 Derived의 생성자가 실행되지않은 상태이므로 프로터피 초기화가 이루어지지 않은 상태이다.
        그러므로 초기값인 0을 출력한다. == 선언만 이루어진 상태이고 초기화가 되어있지 않은 상태
3. Derived 주 생성자 실행 끝나고 Derived의 init 블럭 실행
	3.1 여기서의 this는 Derived를 뜻하며 this.num은 Derived의 num을 뜻함
    	Dervied의 주 생성자의 실행이 끝났으므로 프로퍼티의 초기화까지 끝났으므로 num의 값은 300이다.
        
        
왜 오버라이드된 프로퍼티로 접근되는가?
-> 그 이유는 일반적으로 this는 자기자신을 나타내지만 super()와 같은 부모클래스의 객체를 생성하는 로직이 실행될때
	this를 이용해서 함수를 호출하거나 프로퍼티에 접근할때 해당 함수나 프로퍼티가 오버라이드 되어있다면 this는 앞으로 만들어질 인스턴스의 클래스를 나타낸다[ =Derived ]
    즉 this를 통해 오버라이드 되어있는 프로퍼티나 함수에 접근하려고한다면 그때 this는 하위클래스를 의미한다.
    그 이유는 this의 동적바인딩 때문이다.

 

그래서 이런 문제점을 해결하기 위해서는 상위 클래스가 하위 클래스의 오버라이드된 프로퍼티를 의존하지않게 해야한다.

[ 컴파일러가 Accessing non-final property number in constructor 라고 에러를 발생시켜준다.
-> Base 객체를 생성할때 300으로 number를 초기화해줬다. 이미 number를 초기화해줬는데

Derived 클래스에서 overried했으므로 number가 final하지않다고 판단하는것이다. 

val이므로 setter는 못만들겠지만 Dervied에서 overried한 이상 getter는 재정의 할 수 있다.

즉 Base 객체가 생성될때 number의 getter가 한번 정의됬었는데, override되서 재정의 될 가능성이 있기때문에

number 프로퍼티의 getter는 final하지않은것이고, 결론적으로 number 프로퍼티는 final하지않은것이다.]

 

상위 클래스를 설계할 때 생성자 또는 초기화 블록에 사용되는 프로퍼티에는 open을 피해야 한다

 

this와 동적 바인딩

this는 항상 실제로 실행되는 객체를 가리키는데, 이 객체는 자식 클래스의 인스턴스일 수도 있고 부모 클래스의 인스턴스일 수도 있습니다.

  • 오버라이드가 되어 있는 경우: this는 실제로 자식 클래스의 인스턴스를 가리킵니다. 그래서 자식 클래스에서 오버라이드된 메서드나 프로퍼티에 접근하게 됩니다.
  • 오버라이드가 되지 않은 경우: 부모 클래스에서 정의된 메서드나 프로퍼티에 접근하게 되므로, this는 부모 클래스의 메서드나 프로퍼티를 호출하게 됩니다.

 

즉 기본적으로 this의 동작은 현재 클래스의 객체의 참조를 의미한다.

this.num을 통해 현재 객체의 num을 접근했더니 오버라이드 된 변수이기 때문에 

현재 this.num을 호출하게된 객체는 Dervied 객체이니까  Derived  객체를 가서 num의 값을 가져오게 된다. 

[ 즉 여기서는 this가 Derived를 가리키게 된다. ]

그러나 Dervied 클래스 객체는 아직 필드값이 초기화가 되지않았으니까 0을 반환하게 되는것이다.

 

 

Derived의 생성자가 호출됬을때 진행순서

1. Dervied의 객체가 생성된다 , 하지만 생성자 내부로직은 진행되지않은 상태,

2. Dervied의 부모클래스의 생성자가 실행된다 -> 부모클래스의 객체 생성 및 생성자 로직 진행 완료

3. Derived의 생성자 내부로직이 실행된다. -> 이제서야 생성자 내부로직이 진행되므로 여기서 필드값 초기화라던지,로직이 진행된다.

 

 

 

 

 

위의 코틀린 코드를 자바코드로 변환 했을때)

// 중요한 부분 말고는 다 생략된 코드
public class Base {
   private final int number;

   public Base(int number) {
      this.number = number; // 자바에서는 필드 override가 없으므로 여기서 this는 Base를 뜻한다.
      String var2 = "Base Class";
      System.out.println(var2);
      int var3 = this.getNumber(); // getNumber()가 Derived에서 overried됬으므로 여기서 this는 Dervied를 뜻한다.
      System.out.println(var3);
   }

   public int getNumber() {
      return this.number;
   }

}


public final class Derived extends Base {
   private final int number;

   public Derived(int number) {
      super(number);
      this.number = number;
      String var2 = "Derived Class";
      System.out.println(var2);
   }

   public int getNumber() {
      return this.number;
   }
}



public final class Lec01MainKt {
   public static final void main() {
      new Derived(300);
   }
}

 

 

4. 상속 관련 지시어 정리

 

1. final : override를 할 수 없게 한다. default로 보이지 않게 존재한다.

여기서 final은 오버라이드 불가능을 의미한다. 

 

자바에서 final은 불변을 의미하고 코틀린에서 불변을 의미하는건 val이다.

 

코틀린에서 모든 프로퍼티와 메서드는 암묵적으로 final이다. 즉 override가 기본적으로 막혀있다.

2. open : override를 열어 준다.

기본적으로 override가 막혀있으니 override를 열어주는 지시어(keyword)는 open이다. 

 

3. abstract : 반드시 override 해야 한다.

 

4. override : 상위 타입을 오버라이드 하고 있다.

 

댓글