[Kotlin] 코틀린의 클래스와 상속

Rupee·2023년 3월 22일
2
post-thumbnail

☁️ 클래스

1. 클래스와 프로퍼티

public class JavaPerson {
	private final String name;
    private int age;
    
    public JavaPerson(String name, int age) {
    	this.name = name;
        this.age = age;
    }
    
    public String getName() {
    	return name;
    }
    
    public int getAge() {
    	return age;
    }
    
    public void setAge(int age) {
    	this.age = age;
    }
}

다음 긴 자바 클래스를 코틀린으로 바꾼 코드이다.

class Person constructor(name: String, age: Int) {  // 생성자
	val name: String = name
    var age: Int = age  
}

생성자 지시어와 타입은 생략이 가능하기 때문에 줄이면 다음과 같다. 어떻게 이렇게 간단하게 줄일 수 있는지는 아래서 차근차근 알아보도록 하자.

class Person(name: String, age: Int) {  // 생성자
	val name = name(타입 생략 가능)
    var age = age  
}

1. 자동 프로퍼티

코틀린에서는, val / var 을 통해 필드만 만들어도 게터와 세터를 자동으로 만들어주고, 각각을 프로퍼티라고 칭한다.

또한, 아래와 같이 클래스의 필드 선언 및 생성자, 그리고 게터/세터 메서드가 동시에 선언이 가능하기 때문에(생성자에서 프로퍼티를 만들 수가 있다) 깔끔하게 리팩토링 할 수 있다.

class Person(
	val name: String,
    var age: Int
) {  // BODY 도 아무 내용이 없다면 생략이 가능하다. 

}

🔖 값 조회 방법
객체.getName() -> 객체.name
객체.setName() -> 객체.name = "A"

2. 주 생성자와 init

코틀린에서 생성자는 기본과 보조 생성자로 나뉜다. 주 생성자는 클래스 헤더에 위치하며, 보조 생성자는 클래스 내부에 위치한다.

만약 생성자에서 다음과 같이 검증 로직을 체크해야 하는 경우, init 을 사용하면 된다. init생성자가 호출 되는 시점에 한번만 호출되는 초기화 블럭이다.

 public JavaPerson(String name, int age) {    
 	if (age <= 0) {
        throw new IllegalArgumentException();
    }
    this.name = name;
    this.age = age;
 }
class Person(
	Val name: String,
    var age: Int
) {           // BODY 도 아무 내용이 없다면 생략이 가능하다. 
  init {
  	if (age < 0) {
    	throw IllegalArgumentException()
    }
}

3. 보조 생성자

constructor 는, 기본 생성자 이외의 다른 생성자인 부 생성자를 생성할 때 사용한다.

constructor(name: String): this(name, 1)  // 부 생성자

constructor(): this("홍길동") {  // 혹은 BODY를 가질 수 있다.
     println("부 생성자")
}

중요한 것은, 보조 생성자는 최종적으로 주 생성자this호출해야 한다는 것이다. 또한 이러한 위임은 기본 생성자에 직접 하거나 다른 보조 생성자를 통해서 간접적으로 할 수 있다. 그리고 본문이 호출될 때는 초기화 블럭부터 시작해 역순으로 호출된다.

참고로, 부생성자보다는 default parameter 를 권장한다. 만약 타입 컨버팅과 같은 경우가 필요하다면, 정적 팩토리 메서드를 활용하자.

class Person(
	val name: String = "최태현:",
    var age: Int = 1,
) {
}

3. 커스텀 게터 / 세터

커스텀 게터 / 세터를 정의 할 때, 함수 처럼 사용할 수도 있고 프로퍼티 처럼 사용할 수도 있다.

  1. 함수 방식
fun isAdult(): Boolean {
	return this.age >= 20
}
  1. 프로퍼티 방식
val isAdult: Boolean
	get() {
       return this.age >= 20
    }
val isAdult: Boolean
	get() = this.age >= 20    // 중괄호 + return 제거

참고로 프로퍼티 방식을 사용하면, 해당 게터와 세터에 대응되는 필드가 클래스 내부에 없음에도 밖에서는 게터 함수를 사용할 수 있게 된다.

class Person(
  var name      // 프로퍼티(필드)
) {
  val newName: String  // 실제 존재하는 필드가 X
    get() = this.name + "ABC"
}

p.newName  // newName 이란 필드가 없음에도 사용 가능

4. backing field

코틀린에서는 .name 을 통해 속성을 호출하면 자동으로 게터가 호출된다. 이때, 게터 호출 -> 속성 호출 -> 게터 호출 -> 과 같은 무한 루프가 발생하기 때문에 이를 방지하기 위해 예약어를 하나 더 만들게 된다.

field 는 무한루프를 막기 위한 예약어로, 자기 자신을 가리킨다.

☁️ 상속

1. 추상 클래스 상속

코틀린에서 클래스를 상속받을 때는 extends 가 아닌 : 를 사용한다.

abstract class Animal(
	protected val species: String,
    protected val legCount: Int,
) {
	abstract fun move()
}
class Cat(
	species: String
) : Animal(species, 4) {   // super = 상위 클래스의 생성자를 바로 호출 

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

상속 파트에서 자바와의 차이점은 크게 세 가지로 나뉜다.

  1. species 필드를 받는 기본 생성자가 생긴다.
  2. Animal 클래스의 생성자를 바로 호출해서 값을 부모 생성자로 전달한다.(super 과 같은 역할)
  3. 오버라이드된 클래스에서는 명시적인 수정자override 가 필요하다.

부모 생성자를 부르는 super() 로직이 상속 받을 때 한줄로 이루어진다는 것에 주의하자.

🔖 프로퍼티(게터/세터) 오버라이드
추상 멤버가 아니면 기본적으로 오버라이드가 불가능하다. 따라서 프로퍼티를 오버라이드 할 때는, 상속 받을 때 부모 클래스에 무조건 open 을 붙여주어야 한다. open 은 오버라이드를 허용한다는 뜻이다.

abstract class Animal(
	protected val species: String,
    protected open val legCount: Int,  // open 추가
) {
	abstract fun move()
}
class penguin(
	species: String
) : Animal(species, 2) {

    private val wingCount: Int = 2
    
    override fun move() {
    	println("펭귄")
    }
    
    override val legCount: Int    // 커스텀 게터
    	get() = super.legCount + this.wingCount
}

2. 인터페이스 상속

추상 클래스와 똑같이 상속 할 때는 : 를 사용한다. 단, 코틀린에서는 자바와 다르게 default 키워드를 함수에 붙여 주지 않아도 된다.

interface Flyable {
	fun act() {     // default 메서드
    	println("파닥 파닥")
    }
    
    fun fly()    // 추상 메서드
}

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

class Penguin(
	species: String 
) : Animal(species, 2), Swimable, Flyable {

	private val wingcount: Int = 2
    ...
    
    override fun act() {
    	super<Swimable>.act()   // Swimable.super.act()
        super<Flyable>.act()
    }
}

4. 클래스를 상속 받을 때 주의할 점

상위 클래스를 설계 할 때, 생성자 또는 초기화 블럭에서 사용되는 프로퍼티에는 open 사용을 피해야 한다. open 을 사용하면 상위 클래스의 생성자나 init 블럭에서는, 하위 클래스의 final이 아닌 오버라이드 된 필드에 접근할 수 있게 되기 때문이다.

아래 예시를 봐보자.

main() {
	val test = Derived(
}

open class Base(
	open val number: Int = 100  // open : override 허용
) {
	init {
    	println("Base Class")
        println(number)
    }
}
class Derived(
	override val number: Int
) : Base(number) {
	init {
    	print("Dervied Class")
    }
}

상위 클래스에서 number 필드를 호출하면 오버라이드 된 하위 클래스의 number 를 부르게 된다. 하지만 이때는 상위 클래스의 생성자가 하위 클래스의 number 필드가 초기화 되기 이전에 실행되므로, 값이 0 으로 이상하게 나오게 되는 것이다.

☁️ 접근 제어

1. 자바의 가시성 제어

  1. public : 모든 곳에서 접근 가능하다.
  2. protected : 같은 패키지 혹은 하위 클래스에서만 접근 가능하다.
  3. default : 같은 패키지에서만 접근 가능하다.
  4. private : 선언된 클래스 내에서만 접근 가능하다.

2. 코틀린에서의 가시성 제어

코틀린은 패키지를 namespace 를 관리하기 위한 용도로만 사용하지, 가시성 제어에는 사용되지 않는다. 이에 따라 protecteddefault 키워드가 변경되게 되었다.

  1. protected : 선언된 클래스 또는 하위 클래스에서만 접근 가능하다.
  2. internal : 같은 모듈에서만 접근 가능하므로 상위 모듈은 하위 모듈에 접근이 불가능하다.

🔖 모듈이란?
한 번에 컴파일 되는 Kotlin 코드이다. IDEA Module / Maven Project / Gradle Source Set 등이 존재한다.

또한, 자바에서는 deault 가 기본 접근 제어자인 반면 코틀린은 기본이 public 이다.

3. 생성자의 가시성 제어

생성자에 접근 제어자를 붙이려면, constructor 를 사용해야 한다.

open class Cat protected constructor (

)

그리고 자바에서는 유틸성 코드를 만들 때 인스턴스화를 막기 위해 abstract + private 생성자를 사용하였다. 코틀린도 똑같이 가능하지만, 더 간단하게 파일 최상단에 유틸 함수를 작성하기만 하면 public static final 이 자동으로 붙게 되어 정적 메서드와 같이 사용할 수 있다.

package com.lannstartk.lec11

fun isDirectoryPath(path: String): Boolean {  // public static final 
	return path.endsWith("/")
}

4. 프로퍼티 가시성 제어

게터는 허용하고 세터는 private 으로 막고 싶은 경우, 커스텀 세터를 활용해 세터에만 추가로 가시성을 부여해 줄 수 있다.

class Car(
	internal val name: String,   // 게터와 세터 한번에 접근 제어
    private var owner: String,
    _price: Int    
) {
	var price = _price    // 세터만 접근 제어
    	private set
}

위 코드는 현재 3 개의 게터와, name/owner 에 대한 2 개의 세터가 존재하게 된다.

5. 주의 사항

  1. internal 은 바이트 코드 상 public 이 되기 때문에, 상위 모듈의 자바 코드에서는 하위 코틀린 모듈의 internal 코드를 가져올 수 있게 된다.
  2. 자바는 같은 패키지의 코틀린 protected 멤버에 접근할 수 있다.

☁️ object 키워드 다루기

1. static 함수와 변수

코틀린에서는 따로 static 개념이 존재하지 않는데, 대신 companion object 를 사용하다. 해당 영역 안의 변수와 함수는 정적 변수와 함수로 간주되는 것이다.

🔖 static
클래스가 인스턴스화 될 때 새로운 값이 복제되는것이 아니라, 정적으로 인스턴스끼리의 값을 공유

class Person private constructor (
	private val name: String,
    private val age: Int,
) {
	companion object {
    	private const val MIN_AGE = 0
        fun newBaby(name: String): Person {
        	return Person(name, MIN_AGE)
        }
    }
}
  • const : 런타임이 아닌 컴파일 시에 변수가 할당된다. 진짜 상수에 붙이기 위한 용도로, 기본 타입과 String 에만 붙일 수 있다.

자바와 특이한 점은, companion object 은 곧 객체이기 때문에 인터페이스를 구현하거나 이름을 붙일 수 있다.

companion object Factory : Log {
}

자바에서 코틀린의 정적 메서드나 변수를 부르고자 할 때는, 코틀린 메서드에 @JvmStatic 을 붙여야 companion object 의 이름을 통해서 부르는 복잡한 과정을 거치지 않아도 된다.

Person.Companion.newBaby("ABC");   // 붙이지 않았을 때
Person.Factory.newBaby("ABC");   // 붙이지 않았을 때 + 이름이 존재할 때
Person.newBaby("ABC");  // 붙였을 때

2. 싱글톤

코틀린에서는 object 만 붙임으로써 싱글톤 클래스를 생성할 수 있다.

fun main() {
	println(Singlton.a)
}

object Singleton {   // 싱글톤 객체 = 인스턴스 하나
	var a: Int = 0
}

3. 익명 클래스

익명 클래스란, 특정 인터페이스나 클래스를 상속받은 객체를 일회성으로 사용할 때 사용하는 이름이 없는 클래스이다. object : 타입 과 중괄호로 익명 클래스를 표시할 수 있다.

fun main() {
	moveSomething(object : Movable {
    	override fun move() {
        }
        override fun fly() {
        }
    })
}

private fun moveSomething(movable: Movable) {
	movable.move()
    movable.fly()
}

☁️ 중첩 클래스

1. 중첩 클래스 종류

static 이 붙어있는 내부 클래스는 외부 클래스를 직접 참조할 수 없으며, 내부 클래스는 밖의 클래스를 직접 참조 가능하다는 특징이 존재한다.

public class JavaHouse {
  private String address;
  ...
  
  public static class Living Room {    // 정적 클래스인 경우 
      ...
      public String getAddress() {
          return JavaHouse.this.address;  // 직접적인 참조 불가능
      }
  }
}

단, 내부 클래스가 외부 클래스를 참조함으로 인해 생기는 메모리 누수 및 직렬화 제한의 문제점으로 인해 static 클래스를 사용하는 것을 지양해야 한다.

2. 코틀린의 중첩 클래스와 내부 클래스

코틀린도, 이러한 권장 사항을 따르고 있으므로 그냥 내부에 클래스를 생성하기만 하면 기본적으로 바깥 클래스에 대한 연결이 없는 static 중첩 클래스가 만들어진다.

JavaHouse(
	priavte val address: String
    private val livingRoom : LivingRoom
) {
	class LivingRoom(    // static class
    	private val area: Double
    )
}

바깥 클래스에 대한 참조를 가지는 클래스는, 클래스에 inner 을 붙여주고 값을 가져올 때는 this@클래스.필드 를 사용하면 된다.

JavaHouse(
	priavte val address: String
    private val livingRoom : LivingRoom
) {
	inner class LivingRoom(    // static class
    	private val area: Double
    ) {
    	val address: String
        	get() = this@House.address  // 바깥 참조
    
    }
}

☁️ 기타 다양한 클래스

1. Data Class

계층간의 데이터를 전달하기 위한 DTO(Data Transfer Object) 를 코틀린에서는 어떻게 사용하는지 알아보자.

데이터(필드), 생성자 및 게터, equals, hashCode, toString 이 필요한 경우가 많은데 코틀린에서는 data 키워드를 붙이기만 하면 자동으로 모두 생성해준다.

data class PersonDto(
	val name: String,
    val age: Int,
)

여기에 named argument 까지 활용하면, 빌터 패턴을 쓰는 것과 같은 효과까지 누릴 수 있다.

🔖 참고 사항
자바에서는 JDK 16 부터 data class와 같은 Record 클래스를 도입했다.

2. Enum Class

우선, enum 의 특징은 다음과 같다.

  1. 추가적인 클래스를 상속받을 수 없다.
  2. 인터페이스는 구현할 수 있다.
  3. 각 코드가 싱글톤이다.
enum class Country(
	private val code: String,
){
	KOREA("KO"),
    AMERICA("US")
    ; 
}

코틀린에서는 when 을 이넘 클래스와 함께 사용하면 장점이 있다.

컴파일러가 매개변수의 모든 타입을 알고 있어 다른 타입에 대한 else 처리가 필요 없어지며, 이넘 클래스에 새로운 값이 추가되는 등의 변화가 생기면 IDE 단에서 워닝을 띄워주기 때문이다.

fun handleCountry(country: Country) {
	when (country) {
    	Country.KOREA -> TODO()
        Country.AMERICA -> TODO()
    }
}

3. Sealed Class, Sealed Interface

"상속이 가능하도록 추상 클래스를 만들까 하는데, 외부에서는 이 클래스를 상속 받지 않았으면 좋겠다! 따라서 하위 클래스를 봉인하자."

  1. 컴파일 타임 때 하위 클래스의 타입을 모두 기억하여, 런타임 때 클래스 타입이 추가될 수 없다.(enum 과 같은 특성)
  2. 같은 패키지의 자식 클래스만 상속 가능하다.
  3. 추상 클래스로 직접 객체 인스턴스 생성이 불가능하다.

Enum 클래스보다는 유연하지만, 하위 클래스를 제한하는 클래스인 것이다.

예시를 들어보겠다. sealed 를 붙이지 않은 일반 현대카 클래스를 상속받은 하위 클래스들이 존재한다 가정해보자.

class HyundaiCar(
	val name: string,
    val price: Long
)

class Avante : HyundaiCar("아반떼", 1_000L)
class Grandeur : HyundaiCar("그랜져", 2_000L)

분기 처리를 하려면,다음과 같이 else 구문으로 존재하느 자식 클래스들이 아닌 다른 타입이 들어올 때 처리를 해주어야 한다. 컴파일러는, 부모 클래스만 알지 어떤 자식 클래스들이 존재하는지 모르기 때문이다.

fun handleCar(car: HyundaiCar) {
	when (car) {
    	is Avante -> TODO()
        is Grandeur-> TODO()
        else -> TODO()
    }
}

하지만 다음과 같이 Sealed Class 로 선언해준다면? 상속 받는 자식 클래스의 종류를 제한하니 자식 클래스들의 타입을 컴파일러가 모두 알고 있게 되므로 else 로 처리를 하지 않아도 된다.

sealed class HyundaiCar(
)
fun handleCar(car: HyundaiCar) {
	when (car) {
    	is Avante -> TODO()
        is Grandeur-> TODO()
    }
}

따라서 추상화가 필요한 엔티티나 DTOsealed class 를 활용하는 것을 권장한다. 추가적으로, JDK 17 에서도 sealed class 가 추가되었다.

🔖 Abstract VS Sealed
둘의 가장 큰 차이는 구현체가 같은 패키지가 있어야 하느냐의 여부이다. 실무에서는 주로
Sealed 클래스를 사용하지만, 멀티 모듈을 쓰는 경우에 다른 패키지에서 상속 받고 싶을 때는 abstract 클래스를 사용해야 한다.

[코틀린 탐구생활] when, 그리고 클린 코드

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글