3장 코틀린 객체지향 프로그래밍

코틀린 쿡북 3장을 요약한 내용 입니다.

const와 val의 차이 이해하기

컴파일 타임에 상수를 표기하기 위해서는 const 변경자를 사용해야 한다. val 키워드는 변수에 한 번 할당되면 변경이 불가능함을 나타내지만 이러한 할당은 실행 시간에 일어난다.

컴파일 타임 상수는 반드시 객체나 동반 객체(companion object) 선언의 최상위 속성 또는 멤버여야 한다. 컴파일 타임 상수는 문자열 또는 기본 타입의 래퍼 클래스(Byte, Short, Int, Long, Float, Double, Char, Boolean)이며, 사용자 정의 획득자(getter)를 가질 수 없다.

그럼 작업 우선순위를 위해 최솟값과 최댓값을 정의하는 예제를 구현해보자

class Task(val name: String, _priority: Int = DEFAULT_PRIORITY) {
    companion object {
        const val MIN_PRIORITY = 1
        const val MAX_PRIORITY = 5
        const val DEFAULT_PRIORITY = 3
    }
    
    var priority = validPriority(_priority)
        set(value) {
            field = validPriority(value)
        }
        
    private fun validPriority(p: Int) = 
        p.coerceIn(MIN_PRIORITY, MAX_PRIORITY)
}

컴파일 타임 상수는 컴파일 시점에 값을 사용할 수 있도록 main 함수를 포함한 모든 함수의 바깥쪽에서 할당되어야 한다.

코틀린에서 val 키워드지만 const는 private, inline 등과 같은 변경자임에 유의하자. 그런 이유로 const가 val 키워드를 대체하는 것이 아니라 반드시 같이 쓰여야 한다.

사용자 정의 획득자와 설정자 생성하기

코틀린의 속성에 get과 set 함수를 추가하여 사용하자.

코틀린은 특이하게도 모든 것이 기본적으로 public이다. 따라서 정보와 연관된 데이터 구조의 세부 구현이 필요하다고 추정되며 이는 데이터 은닉 원칙을 침해하는 것처럼 보인다.

class task(val name: String) {
    var priority = 3
		// ...
}

Task 클래스는 name과 priority라는 두 가지 속성을 정의한다. 속성 하나는 주 생성자 안에 선언된 반면에 다른 속성은 클래스의 최상위 멤버로 선언되었다.

priority의 단점은 무엇일까?

이 방식으로 priority를 선언할 때의 단점은, apply 블록을 사용해서 priority에 값을 할당할 수 있지만 클래스를 인스턴스화할 때 priority에 값을 할당할 수 없다는 것이다.

var myTask = task().apply { priority = 4 }

그렇다면 장점은 없을까?

이 방식으로 속성을 정의하면 쉽게 사용자 정의 획득자와 설정자를 추가할 수 있다는 것이다.

var <propertyName>[ :<PropertyType>] [= <property_initializer>]
    [<getter>]
    [<setter>]

사용자 정의 획득자

val isLowPriorty
    get() = priority < 3 // 리턴 타입은 Boolean

사용자 정의 설정자

var priority = 3
    set(value) {
        field = value.coerceIn(1..5) // 1과 5사이의 값이 되어야 한다. 
    }

위와 같이 작성하면 priority값이 반드시 1과 5사이의 값이 설정되도록 제한을 둘 수 있다.

class Task(val name: String, _priority: Int = DEFAULT_PRIORITY) {
	
	val priority = validPriority(_priority)
		set(value) {
			feild = validPriority(value)
		}
	...
}

파라미터 _priority는 속성이 아니라 생성자의 인자일 뿐이다. 이 인자는 실제 priority 속성을 초기화하는 데 사용되고 사용자 정의 설정자는 우선순위 값을 원하는 범위로 강제하기 위해서 그 값이 변경될 때마다 실행된다.

데이터 클래스 정의하기

equals, hashCode, toString 등이 완벽하게 갖춰진 엔티티를 나타내는 클래스를 생성하고 싶을 경우 클래스를 정의할 때 data 키워드를 사용하라

클래스 정의에 data를 추가하면 코틀린 컴파일러는 일관된 equals와 hashCode 함수, 클래스와 속성 값을 보여주는 toString 함수, copy 함수와 구조 분해를 위한 component 함수 등 일련의 함수를 생성한다.

data class Product {
	val name: String,
	val price: Double,
	val onSale: Boolean = false
}

코틀린 컴파일러는 주 생성자에 선언된 속성을 바탕으로 equals와 hashCode 함수를 생성한다.

@Test
fun `create set to check equals and hashcode`() {
	val p1 = Product("baseball", 10.0)
	val p2 = Product(price = 10.0, onSale = false, name = "baseball")

	val products = setOf(p1, p2)
	assertEquals(1, products.size) // true
}

setOf 함수에 p1, p2 둘 다 원소로 추가했지만 실제로는 p1과 p2는 동드아기 때문에 오직 한 product만 추가된다.

copy 함수는 깊은 복사가 아니라 얕은 복사를 수행한다는 점에 유의하자

data class OrderItem(val product: Product, val quantity: Int)

다음 테스트는 OrderItem을 인스턴스화한 다음 copy 함수를 이용해 복사본을 만든다.

@Test
fun `data copy function is shallow`() {
	val item1 = OrderItem(Product("baseball", 10.0), 5)
	val item2 = item1.copy()

	assertAll(
		{ assertTrue(item1 == item2) }, // true
		{ assertTrue(item1 === item2) }, // false
		{ assertTrue(item1.product == item2.product) }, // true
		{ assertTrue(item1.product === item2.product) }, // true
	)
}

item1.product와 item2.product의 === 연사자는 true 이므로 같은 내부 Product 인스턴스를 공유하는 것을 알 수 있다.

copy 함수뿐만 아니라 데이터 클래스에는 추가로 속성 값을 리턴하는 함수 component1, component2 등이 있다.

@Test
fun `destructure using component functions`() {
    val p = Product("baseball", 10.0)
    
    val (name, price, sale) = p
    
    assertAll(
        { assertEquals(p.name, name) },
        { assertThat(p.price, `is`(closeTo(price, 0.01))) },
        { assertFalse(sale) }
    )
}

지원 속성 기법

클래스의 속성을 클라이언트에게 노출하고 싶지만 해당 속성을 초기화하거나 읽는 방법을 제어해야 할 경우 같은 타입의 속성을 하나 더 정의하라

class Customer(val name: String) {
	private var _messaged: List<String>? = null // 널 허용 private 속성의 초기화

	val messages: List<String> // 외부로 노출되는 get 속성
		get() { // private 함수
			if (_messages == null) {
				_messaged = loadMessages()
			}
			return _messages!!
		}

	private fun loadMessages(): MutableList<String> = 
		mutableListOf(
			"Initial contact",
			"Convinced them to use Kotlin",
			"Sold training class, Sweet."
		).also { println("Loaded messages") }
}

테스트 코드를 작성해보자

@Test
fun `load messages`() {
	val customer = Customer("Fred").apply { messages } // messages를 처음 사용하기 때문에 로딩
	assertEquals(3, customer.messages.size) // messages는 이미 로딩 되었음
}

위와 같이 messages를 바로 불러오려면 apply 함수를 사용한다. 이 테스트의 apply 블록에서 messages 호출은 messages를 불러오고 로딩 완료 정보를 출력하는 getter 메소드를 호출한다. 두 번째로 messages 속성에 접근할 때 messages는 로딩됐기 때문에 로딩 완료 정보를 출력하지 않는다.

하지만 lazy 대리자 함수를 사용하면 더 쉬운 방법으로 코드를 구현할 수 있다.

class Customer(val name: String) {
    
    val messages: List<String> by lazy { loadMessages() } // lazy 사용
    
    private fun loadMessages() : MutableList<String> = 
        mutableListOf(
            "Initial contact",
						"Convinced them to use Kotlin",
						"Sold training class, Sweet."
        ).also { println("loaded messages") }

		...
}

또는 이전 예제 처럼 생성자 인자를 제공하여 초기화를 강제 하는 방법도 있다.

class Task(val name: String, _priority: Int = DEFAULT_PRIORITY) { 
		//_priority 가 backing property 로 보면 
    
    var priority = validPriority(_priority)
        set(value) {
            field = validPriority(value)
        }
        
    private fun validPriority(p: Int) = 
        p.coerceIn(MIN_PRIORITY, MAX_PRIORITY)
}

연산자 중복

코틀린의 연산자 중복(overloading) 매커니즘을 사용해서 +와 * 등의 연산자와 연관된 함수를 구현할 수 있다.

코틀린 공식 문서의 모범 예제에서는 다음과 같은 코드를 보여준다.

data class Point(val x: Int, val y: Int)

operator fun Point.unaryMinus() = Point(-x, -y) // operator 를 붙여서 구현하자

val point = Point(10,20)

fun main() {
    println.(-point) // Point(-10,-20) 출력 
}

자신이 작성하지 않은 클래스에 함수를 추가하고 싶다면?

확장함수를 사용해 자신이 작성하지 않은 클래스에도 연산자와 연관된 함수를 추가할 수 있다.

import org.apache.commons.math3.complex.Complex

operator fun Complex.plus(c: Complex) = this.add(c)
operator fun Complex.plus(c: Double) = this.add(c)
operator fun Complex.minus(c: Complex) = this.subtract(c)
operator fun Complex.minus(c: Double) = this.subtract(c)
...

테스트에서 위임된 연산자 함수를 사용하는 방법을 보자

val first = Complex(1.0, 3.0)
val second = Complex(2.0, 5.0)

@Test
internal fun plu() {
	val sum = fitst + second
	assertThat(sum, `is`(Complex(3.0, 8.0))
}

나중 초기화를 위해 lateinit 사용하기

초기화를 위한 정보가 충분하지 않으면 해당 속성을 널 비허용 속성으로 만들기 위해 lateinit 변경자를 사용하라

가끔은 속성에 할당할 값의 정보가 충분하지 않는 경우가 있다. 이것은 모든 객체가 생성될 때까지 의존성 주입이 일어나지 않는 의존성 주입 프레임워크에서 발생하거나 유닛 테스트의 설정 메소드 안에서 발생한다. 이러한 경우를 대비해 속성에 lateinit 변경자를 사용한다.

class OfficerControllerTests {
    @Autowired
    lateinit var client : WebTestClient
    
    @Autowired
    lateinit var repository: OfficerRepository
    
    @Before
    fun setUp() {
        repository.addTestData() 
    }
    
    @Test
    fun `Get to route returns all officiers in db`() {
        client.get().uri("/route")
        ...
    }
}

lateinit 변경자는 클래스 몸체에서만 선언되고 사용자 정의 획득자와 설정자가 없는 var 속성에서만 사용할 수 있다. 코틀린 1.2부터 최상위 속성과 지역 변수에서도 lateinit을 사용 가능하다. lateinit을 사용할 수 있는 속성 타입은 널 할당이 불가능한 타입이어야 하며 기본 타입에는 lateinit을 사용할 수 없다.

class LateInitDemo {
	lateinit var name: String
}

class LateInitDemoTest {
	@Test
	fun `unitialized lagteinit property throws exception`() {
		assertThrows<UnitializedPropertyAccessException> {
			LateInitDemo().name
		}
	}

	...
}

테스트에서 보듯이 초기화되기 이전에 name 속성에 접근하면 UninitializedPropertyAccessException을 던진다. 이럴 경우에는 클래스 내부에서 속성 래퍼런스의 isInitialized를 사용하면 해당 속성의 초기화 여부를 확인할 수 있다.

lateinit var name: String

fun initializeName() {
    println("Before assignment: ${::name.isInitialized}") // false
    name = "World"
    println("After assignment: ${::name.isInitialized}") // true
}

fun main() {
	LateInitDemo().initializeName()
}

lateinit과 lazy의 차이

lateinit 변경자는 var 속성에서 사용하고 lazy 대리자는 속성에 처음 접근할 때 평가되는 람다를 받는다.

초기화 비용이 높은데(?) lazy를 사용한다면 초기화는 반드시 실패한다. 또한 lazy는 val 속성에 사용할 수 있는 반면 lateinit은 var 속성에만 적용할 수 있다. 마지막으로 lateinit 속성은 속성에 접근할 수 있는 모든 곳에서 초기화할 수 있기 때문에 앞의 예제에서처럼 객체 바깥쪽에서도 초기화할 수 있다.

equals 재정의를 위해 안전 타입 변환, 레퍼런스 동등, 엘비스 사용하기

논리적으로 동등한 인스턴스인지를 확인하도록 클래스의 equals 메소드를 구현하고 싶을 경우, 레퍼런스 동등 연산자(===), 안전 타입 변환 함수(as?), 엘비스 연산자(?:)를 다 같이 사용하라

자바에서는 두개의 등호 연산자(==)가 서로 다른 레퍼런스에 같은 객체가 할당됐는지 여부를 확인하는 데 사용된다. 코틀린에서는 == 연산자는 자동으로 equals 함수를 호출한다.

equals 문법에서 equals 구현은 반사성(reflexive), 대칭성(symmetric), 추이성(transitive), 일관성(consistent)이 있어야 하며 널(null)도 적절하게 처리할 수 있어야 한다.

어떻게 하면 equals 함수를 잘 구현할 수 있을까?

equals 구현의 좋은 예는 코틀린 표준 라이브러리가 제공하는 KotlinVersion 클래스의 equals이다.

overrite fun equals(other: Any?): Boolean {
    if (this === other) return true // 레퍼런스 동등성을 확인 
    val otherVersion = (other as? KotlinVersion) ?: return false
    // 인자를 원하는 타입으로 변환하거나 널을 리턴하는 안전 타입 변환 연산자 as? 사용
    // null 이면 false return 
    return this.version == otherVersion.version
    // 현재 인스턴스의 version 속성이 other 객체의 version 속성과 동등한 여부 리턴 
}

다음의 과정을 순서대로 확인해보자

  • 먼저 ===으로 래퍼런스 동등성을 확인

  • 그다음, 인자를 원하는 타입으로 변환하거나 널을 리턴하는 안전 타입 변환 연산자 as?를 사용

  • 안전 타입 변환 연산자가 널을 리턴하면 같은 클래스의 인스턴스가 아니므로 동등일 수 없기 때문에 엘비스 연산자 ?:는 false를 리턴

  • 마지막으로 equals 함수의 마지막 줄은 (이 예제 코드에서 보이지 않는) 현재 인스턴스의 version 속성이 (== 연산자를 사용해서) other 객체의 version 속성과의 동등 여부를 검사해 결과를 리턴

싱글톤 생성하기

클래스당 인스턴스는 하나만 생성하고 싶을 경우 class 대신 object 키워드를 사용하라

싱글톤을 정의하는 방법은 다음과 같다

  1. 클래스의 모든 생성자를 private으로 정의한다.

  2. 필요하다면 해당 클래스를 인스턴스화하고 그 인스턴스 레퍼런스를 리턴하는 정적 팩토리 메소드를 제공한다.

자바에서 싱글톤 구현 예

자바 표준 라이브러리에서 싱글톤 예는 Runtime 클래스를 들 수 있다.

fun main() {
	val processors = Runtime.getRuntime().availableProcessors()
	println(processors)
}

다음은 java.lang.Runtime에서 싱글톤과 관련된 부분이다.

public class Runtime {
	private static final Runtime currentRuntime = new Runtime();

	public static Runtime getRuntime() {
		return currentRuntime;
	}

	/** 다른 사람이 이 클래스를 인스턴스화 하지 못하게 하자 */
	private Runtime() {}

	// ...
}

코틀린 싱글톤 선언

코틀린에서 싱글톤 구현은 class 대신 object 키워드를 사용하기만 하면 된다.

object MySingleton {
	val myProperty = 3
	fun myFunction() = "HELLO"
}

생성된 바이트코드를 디컴파일하면 결과는 다음 예제와 비슷하다

public final class MySingleton {
	private static final int myProperty = 3;
	public static final MySingleton INSTANCE;

	private MySingleton() {}
	
	public final int getMyProperty() {
		return myProperty;
	}

	public final String myFunction() {
		return "Hello"
	}

	static {
		MySingleton var0 = new MySingleton();
		INSTANCE = var0;
		myProperty = 3;
	}
}

코틀린 object의 멤버 함수와 속성은 필요한 모든 획득자 메소드와 함께 디컴파일된 자바 클래스의 static final 메서드와 속성으로 변환되고 이 속성들은 자신의 클래스와 함께 sdtatic 블록에서 초기화된다.

// 코틀린에서 싱글톤 인스턴스 접근 방법
MySingleton.myFunction()
MySingleton.myProperty

// 자바에서 싱글톤 인스턴스 접근 방법
MySingleton.INSTANCE.myFunction()
MySingleton.INSTANCE.myProperty

Nothing에 관한 야단법석

절대 리턴하지 않는 함수에 Nothing을 사용해보자

코틀린의 Nothing 클래스의 전체 구현은 아래와 같다.

public class Nothing private constructor()

private 생성자는 클래스 밖에서 인스턴스화할 수 없다는 것을 의미하고, 보다시피 클래스 안쪽에서도 인스턴스화하지 않는다. 따라서 Nothing의 인스턴스는 존재하지 않는다. 코틀린 공식 문서에는 "결코 존재할 수 없는 값을 나타내기 위해 Nothing을 사용할 수 있다"고 명시되어 있다.

Nothing클래스를 사용하는 예를 알아보자

함수 몸체가 전적으로 예외를 던지는 코드로 구성된 상황이다.

fun doNothing(): Nothing = throw Exception("Nothing at all")

리턴 타입을 반드시 구체적으로 명시해야 하는데 해당 메소드는 결코 리턴하지 않으므로(대신 예외를 던짐) 리턴 타입은 Nothing이다.

초기화를 할 경우 구체적인 타입을 명시하지 않은 경우다.

val x = null

x는 분명히 널 할당이 가능한 타입이고 컴파일러는 x에 대한 다른 정보가 없기 때문에 추론된 x의 타입은 Nothing?이다. 그리고 Nothing 클래스는 실제로 다른 모든 타입의 하위 타입이라 어떤 타입도 할당할 수 있다.

for (n in 1..0) {
	val x = when (n%3) {
		0 -> "$n % 3 == 0"
		1 -> "$n % 3 == 1"
		2 -> "$n % 3 == 2"
		else -> throw Exception("Houston, we have a problem...")
	}
	assertTrue(x is string) // true
}

when 식은 값을 리턴하기 때문에 컴파일러는 하나도 빠뜨리지 않고 철저하게 값을 요구한다. 예외가 발생하는 경우 리턴 타입은 Nothing이고 String은 String이므로 컴파일러는 x의 타입이 String임을 알 수 있다.

TODO 함수의 구현은 NotImplementedError를 던지므로 TODO 함수는 Nothing을 리턴하는 것이 타당하다

Last updated