아토믹 코틀린 2부 객체 소개

smpl published on
9 min, 1780 words

Tags: kotlin

아토믹 코틀린 책의 2부 객체 소개 챕터를 읽고, 읽은 내용을 정리해봅니다. 공부하면서 개인적으로 다른 언어와 달랐던 점, 인상깊었던 점 혹은 기억해야할 점들만 적었습니다.

객체는 모든 곳에 존재한다

모르는 함수나 클래스는 코틀린 표준 라이브러리 문서를 찾아본다.

코틀린 플레이그라운드는 언어나 라이브러리 사용법을 익히기에 유용하다. 자동완성 추천도 해준다.

IntRange도 객체이다.

val a = 1..10
println(a.sum())
val r = IntRange(0, 10)
println(r.sum())
val s = "AbCd"
println(s.reversed())
println()

멤버 함수 중에는 타입을 변환하는 함수도 있다.

"123".toInt()
123.toString()
123.toLong()

클래스 만들기

// 클래스 한줄 정의
class Bear

class Cat {
    fun meow() = "mrrrow!"

    fun exercise() = this.meow() + " " + meow() + " Cat is running on wheel"
}

fun main() {
    // 객체 생성
    val b = Bear()

    // 객체 인스턴스 식별자(?) 찍기
    println(b) // Bear@74a1...

    val c = Cat()
    println(c.meow())
    println(c.exercise())
}

프로퍼티

프로퍼티를 정의함으로써 클래스 안에서 상태를 유지한다.

class Cup {
    var percent = 0
    val max = 100

    fun add(increase: Int): Int {
        percent += increase
        if (percent > max) {
            percent = max
        }
        return percent
    }
}

fun main() {
    var c1 = Cup()
    c1.percent = 50
    c1.percent = 70
    c1.add(20)
    println(c1.percent)
}

클래스나 스코프 밖에 최상위 프로퍼티(전역변수)를 정의할 수 있으나, val은 괜찮지만 var를 최상위 프로퍼티로 선언하는 것은 안티패턴으로 간주된다. 프로그램이 복잡해질 수록 공유된 가변 상태에 대해 제대로 추론하기가 어려워지기 때문이다.

var와 val은 그 변수가 가리키는 값(참조)을 변경하는 것을 방지하는 것으로, 값 자체의 내부를 바꾸는 것은 방지할 수 없다. var와 val이 객체가 아니라 참조를 제한한다는 점. 객체의 가변성 개념은 객체의 내부 상태를 바꿀 수 있는가 기준으로 이야기한다. 예를 들어 아래에서 Mouse는 가변객체, Keyboard는 불변객체이다.

class Keyboard {
    val brand: String = "Happy hacking!"
}

class Mouse {
    var brand: String = "Logitech"
}

fun main() {
    var kbd = Keyboard()
    // kbd.brand = "FC750R"     // error!
    kbd = Keyboard()            // change with new instance

    val mse = Mouse()
    mse.brand = "Microsoft"     // ok.
    // mse = Mouse()            // error!
}

생성자

class Alien(shape: String) {
    val greeting = "Hello ugly $shape shaped alien!"
}

class MutableAlien1(var shape: String) { // 생성자를 바로 멤버 변수 - 프로퍼티로
    val greeting1 = "Hello ugly $name shaped alien!"
    var greeting2 = "Hello handsome $name shaped alien!"
}

// 복잡한 생성자 컨벤션 참고..
class UnspecifiedAlien(
    val name: String,
    val eyes: Int,
    val heads: Int,
    val hands: Int,
    val legs: Int,
    val tails: Int
) {
    fun hasTails(): Boolean = tails > 0

    // 기본 toString() 을 명시적으로 override 하겠다. override를 명시함으로써 코드의 의도를 더 명확히 할 수 있고 의도하지 않게 같은 이름의 함수를 정의하는 등의 실수를 줄일 수 있다. 여기서는 override를 명시하지 않으면 컴파일 시 에러가 난다.
    override fun toString(): String {
        return "Alien with $eyes eyes, $heads heads, $hands hands, $legs legs and $tails tails is named as $name"
    }
}

fun main() {
    val a = Alien("human")
    println(a.greeting)
    // a.shape = "animal" // error!

    val m = MutableAlien("rectangle")
    println(m.greeting1)
    m.shape = "octopus"
    println(m.greeting1) // result not changed
    println(m.greeting2) // result not changed
}

가시성 제한하기

가시성을 제한하는 것은 리팩터링 시 라이브러리를 만든 사람이 자신이 변경한 내용이 클라이언트의 코드에 영향을 끼치지 않는다는 확신을 바탕으로 라이브러리를 자유롭게 수정하고 개선할 수 있게 한다.

소프트웨어를 설계할 때 가장 우선적으로 고려해야 하는 내용은 다음과 같다. 변화해야 하는 요소와 동일하게 유지해야하는 요소를 분리한다.

다음과 같은 접근 변경자들이 있다.

  • public : 붙이지 않으면 public이다. 클라이언트 코드에 직접적으로 영향을 끼친다.
  • internal : 모듈 안에서만 접근할 수 있다. 하나의 모듈은 여러개의 package로 구성된다.
  • private : 같은 클래스에 속한 멤버들만이 접근할 수 있다.
    • private가 붙은 클래스, 최상위 프로퍼티, 최상위 함수는 같은 파일 내부에서만 접근이 가능하다.

참고. 클래스 내부에 있는 참조를 private로 정의한다고 해도, 그 참조가 가리키는 객체가 외부에서 주입된 것이라면 그 객체를 가리키는 public 참조가 없다고 보장할 수 없다. 이렇게 한 객체에 대한 참조를 여러개 유지하는 것을 Aliasing 이라고 한다. 이로 인해 놀랄 만한 동작을 수행할 수도 있다.(좋은 의미인지 나쁜 의미인지 모르겠다.)

class Counter(var start: Int) {
    fun inc() { start += 1}
    override fun toString(): String = start.toString()
}

class CounterHolder(private val counter: Counter) {
    override fun toString(): String = "CounterHolder: " + counter
}

fun main() {
    val c = Counter(1)
    c.inc()
    println(c)

    val ch = CounterHolder(c)
    println(ch)

    c.inc()

    println(ch)
}

패키지

package 선언법. 파일에서 주석이 아닌 코드의 가장 앞에 와야 한다. package name은 소문자여야 한다.

코틀린에서는 파일 이름이나 패키지 이름을 아무 것이나 쓸 수 있다. 그러나 패키지 이름과 패키지 파일이 들어있는 디렉토리의 경로를 똑같이 하는게 좋은 관례로 여겨진다.

다른 파일에 정의된 이름과의 충돌을 막기 위해, main 함수가 들어있는 파일이 아닌 모든 파일에는 각자 package 문을 사용하는 것이 좋다. main 함수가 있는 파일은 보통 package 문을 사용하지 않는다.

package animals

class Cat {
    fun meow() { println("meow!") }
}
// import 사용법 
// import packagename.ClassName
// import packagename.functionName
// import packagename.propertyName
import kotlin.math.E
import kotlin.math.sin

import kotlin.math.PI as circleRatio
import kotlin.math.cos as cosine

import kotlin.concurrent.*

import animals.Cat

fun main() {
    println(E)
    println(kotlin.math.E)
    println(circleRatio)

    println(sin(circleRatio))
    println(cosine(circleRatio * 2))

    val c = Cat()
    c.meow()
}

테스트

  • JUnit : 자바에서 가장 널리 쓰이는 테스트 프레임워크이며, 코틀린에서도 유용하게 쓸 수 있다.
  • Kotest : 코틀린 전용으로 언어의 여러 기능을 살려서 작성되었다.
  • Spek : 명세 테스트(specification test)라는 다른 형태의 테스트를 제공한다.

테스트 주도 개발의 이득에 대한 설명. '이 결과를 어떻게 테스트하지?'라고 스스로에게 질문을 던지게 되면, 테스트 외의 다른 이유가 없더라도 함수가 무언가를 반환하게 만들게 되며, 입력을 받아서 출력을 만들어내고 다른 부수효과를 하지 않는 함수를 만들게 되며 설계와 테스트가 함께 나아지게 된다.

예외

저자는 'exception'은 'I Take exception to that(나는 그에 반대한다)'이라는 문장에 있는 exception과 같은 뜻으로 사용된다고 설명한다. 프로그램이 진행되는 과정에서 비정상이라고 가정한, 일반적인 정상경로를 벗어나는 상황이 발생할 경우, 프로그래머가 반대하는 상황이 발생했다는 의미라고 해석..

일반적인 문제와 예외적인 문제를 구분하는 것이 중요.

  • 일반적인 문제 : 그 문제를 처리하기에 충분한 정보가 현재 맥락에 존재하는 경우.
  • 예외 상황 : 이 상황에서는 현재 맥락에서 처리를 계속 해나갈 수 없고, 현재 상황을 벗어나기 위해 문제를 바깥쪽 맥락으로 던지는 것 뿐이다.

예외를 잡아내지 않으면 스택 트레이스가 출력된다.

이 부분은 atomictest를 이용해 설명하고 있기 때문에, 차라리 공식 문서의 예외 설명을 읽는 것이 나은 것 같다.

try {
    throw Exception("Hi, there! There is a little problem.")
}
catch (e: SomeException) {
    handler
}
finally {
    // optional finally block
}

try는 expression이다.

val a: Int = try { input.toInt() } catch (e: NumberFormatException) { 0 }

코틀린은 자바의 Checked exception을 사용하지 않는다. 대신 라이브러리 사용자에게 경고를 하고 싶다면, @Throws annotation을 사용할 수 있다.

throw expression은 Nothing 타입을 갖는다. 이 타입은 값을 갖고 있지 않으며 해당 코드 지점에 절대 도달하지 않을 것임을 마킹하며, 동시에 해당 함수가 리턴되지 않을 것임을 의미한다.

fun fail(message: String): Nothing {
    throw IllegalArguementException(message)
}

표준 라이브러리 Exception 항목으로부터 상속 구조를 따라가며 표준 라이브러리 안의 Exception 클래스들을 둘러볼 수 있다.

리스트

List는 표준 코틀린 패키지에 들어 있기 때문에 import를 할 필요가 없다. listOf 라는 표준 라이브러리 함수로 초기화와 함께 리스트를 만들 수 있다. 인덱스는 0부터 시작한다.

val ints = listOf(1, 2, 3, 4, 5)
for (i in ints) {
  println(i)
}
println(ints[4])
println(ints[5]) // ArrayIndexOutOfBoundException

List의 멤버 함수들 중 sorted, reversed와 같은 함수들은 결과로 새로운 List를 돌려줌. 이와는 달리 원본 List를 바로 수정하는 경우는 in place라고 부른다.

타입 파라미터 <>. 예) List, List, ...

mutableListOf 는 MutableList를 돌려준다. MutableList도 List로 취급할 수 있다. 하지만 그 역은 가능하지 않다.

List는 읽기 전용이다 그래서 내용을 읽을 수는 있어도 안에 값을 쓸 수는 없다. 내부 구현을 MutableList로 하면서 이 MutableList에 대한 참조를 유지했다가, 나중에 이 가변 List에 대한 참조를 통해 원소를 변경하면, 읽기 전용 리스트에 대한 참조임에도 불구하고 그 리스트 내부가 바뀐 모습을 볼 수 있다. (aliasing의 예)

fun getList(): List<Int> {
  return mutableListOf(1, 2, 3)
}

+= 연산자를 쓰면 MutableList일 경우 in place로 붙여주며, List이면 새로운 List를 리턴해주지만, 이 때 변수는 var이어야만 재대입이 가능하다.

가변 인자 목록

vararg는 가변 인자 목록의 줄임말이다. 함수 정의에는 vararg로 선언된 인자가 최대 하나만 있어야 한다. 파라미터 목록에서 어떤 위치에 있는 파라미터이든 vararg로 선언할 수 있지만, 일반적으로 마지막 파라미터를 vararg로 선언하는게 일반적이다. vararg 인자에는 0개 이상의 임의의 갯수의 타입이 맞는 값들을 전달할 수 있다.

fun vf(s: String, vararg i: Int) {
  println("s: $s, size: ${i.size}, sum: ${i.sum()}, avg: ${i.average()}")
}

fun main() {
  vf("abc", 1, 2)
  vf("def", 1, 2, 3)
  vf("ghi")
}

vararg에는 Array타입을 만들어서 넘길 수도 있다. arrayOf()로 초기화된 Array를 만들 수 있고, Array는 불변타입은 없다. Array를 인자 목록으로 변환해서 넘기기 위해 스프레드연산자 *를 사용한다. 프리미티브 타입의 Array를 만들 때는 구체적인 이름의 생성 함수를 사용해야 한다.

val array = intArrayOf(4, 5) // Array<Int> 타입을 생성하는 생성 함수
sum(1, 2, 3, *array, 6)

스프레드 연산자는 배열에만 적용 가능하며, vararg로 받은 인자를 다시 다른 vararg를 받는 함수에 넘길 때도 사용할 수 있다.

fun inner(vararg numbers: Int): String {
  var result = ""
  for (i in numbers) {
    result += "[$i]"
  }
  return result
}

fun outer(vararg numbers: Int): String {
  inner(*numbers)
}

프로그램의 인자는 다음과 같이 구할 수 있다.

fun main(args: Array<String>) {
  for (a in args) {
    println(a)
  }

  val first = args[0]
  val second = args[1].toInt()
}

집합

val intSet = setOf(1, 2, 3, 4, 5, 5, 5) // 중복 없음. 1 2 3 4 5. 순서는 중요하지 않음.  선언한 순서와 다를 수 있음.
3 in intSet // 원소 검사
intSet.contains(3)
intSet.containsAll(setOf(2, 3, 4)) // 다른 집합을 포함하는지 검사
intSet.union(setOf(4, 5, 6, 7)) // 합집합
intSet intersect setOf(0, 1, 2) // 교집합 1 2
intSet subtract setOf(0, 1, 2) // 차집합 0 3 4 5

val list = listOf(3, 3, 2, 1, 2)
list.toSet() // setOf(1, 2, 3)
list.distinct() // listOf(3, 2, 1)
"abcc".toSet() // setOf('a', 'b', 'c')

val mutableSet = mutableSetOf<Int>()
mutableSet += 123
mutableSet += 123 // == setOf(123)
mutableSet -= 123 // == setOf<Int>()

val mathMap = mapOf(
  "PI" to 3.141,
  "e" to 2.718,
  "phi" to 1.618
)

mathMap["e"] // == 2.718
mathMap.keys // == setOf("PI", "e", "phi")
mathMap.values // == [3.141, 2.718, 1.618]
for (entry in mathMap) {
  println("${entry.key} = ${entry.value}")
}
for ((key, value) in mathMap) {
  println("${key} = ${value}")
}

val m = mutableMapOf(5 to "five", 6 to "six")
m[5] // == "five"
m[5] = "FIVE"
m[5] // == "FIVE"
m += 4 to "four"

mapOf와 mutableMapOf는 원소가 Map에 전달된 순서를 유지해준다 다른 Map 타입에서는 이 순서가 보장되지 않을 수 있다.

주어진 키에 해당하는 원소가 포함되어 있지 않으면 Map은 null을 반환한다. null이 될 수 없는 결과를 원한다면 getValue()를 쓸 수 있고, 이 경우 NoSuchElementException이 발생한다. 이보다는 getOrDefault()가 좀더 나은 대안이다.

클래스 인스턴스도 맵의 값이 될 수 있다. (키로도 가능은 하지만 설명하지 않고 뒤로 미룸.)

Map은 연관배열(associative array)이라고 부르기도 한다.

프로퍼티 접근자

프로퍼티 접근자는 getter와 setter를 정의할 수 있게 한다. 프로퍼티 정의 바로 뒤에 get() 혹은 set()을 정의하면 프로퍼티 접근자를 정의할 수 있다.

class Integer {
  var i: Int = 0
    get() {
      println("get()")
      return field // field라는 이름으로 프로퍼티에 접근할 수 있다.
    }
    set(value) {
      println("set()")
      field = value
    }
}

class Counter {
  var value: Int = 0
    private set           // get은 정의하지 않았으므로 set만 private로 외부에서 접근 불가.
  fun inc() = value++
}

// 필드가 없는 프로퍼티를 정의하는 예
class Changeable(private val maxCount: Int) {
  private var changeCount = 0
  val remain: Int
    get() = maxCount - changeCount
  val changeable: Boolean
    get() = remain > 0
  fun change(): Boolean {
    if (!changeable) {
      false
    }
    else {
      changeCount++
      true
    }
  }
}

코틀린 스타일 가이드에서는 계산 비용이 많이 들지 않고 객체 상태가 바뀌지 않는 한 같은 결과를 내놓는 함수의 경우 프로퍼티를 사용하는 편이 낫다고 안내한다. 그리고 프로퍼티 접근자는 프로퍼티에 대한 일종의 보호수단 역할을 할 수 있다.