Kotlin

[Kotlin] 함수와 변수 기초

sh1mj1 2023. 12. 2. 17:07

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처 https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

간단한 Hello, World! 살펴보기

fun main(args: Array<String>) {
     println("Hello, World!")
 }

 

매우 간단한 코드이다. 이 코드만으로도, 자바와 다른 코틀린 문법과 특성을 찾을 수 있다.

  • `fun` 키워드를 사용하여 함수를 선언한다.
  • 파라미터 이름 뒤에 그 파라미터의 타입을 쓴다. 
  • 함수를 최상위 수준에 정의할 수 있다. 자바와 달리 꼭 클래스 안에 함수를 넣을 필요가 없다.
  • 배열도 일반적인 클래스와 똑같이 취급한다.(`Array<T>`의 형태로) 자바와 달리 배열 처리를 위한 문법이 따로 존재하지 않는다.
  • 자바의 `System.out.println` 대신, `println` 이라고 쓴다. 이처럼 코틀린 표준 라이브러리는 여러 표준 자바 라이브러리 함수를 간결히 사용할 수 있도록 감싼 Wrapper 를 제공한다.
  • 자바와 달리 줄 끝에 `;` 을 붙이지 않는다.

함수: 문장(statement)과 식(expression)

식은 값을 만들어 내고, 계산에 참여할 수 있다.

문장은 자신을 둘러싼 블록의 최상위 요소이며, 아무런 값을 만들어내지 않는다.

코틀린에서 `if` 는 식이다. 

반면에 대입문은 자바에서는 식이지만, 코틀린에서는 문장이다. 

 

문장(statement)으로 만들어낸 `max` 함수

fun max (a: Int, b: Int) : Int {
    return if (a > b) a else b
}

 

식(expression) 으로 만들어낸 `max` 함수

fun max (a:Int, b: Int) : Int = if (a > b) a else b

 

코틀린에서는 expression body 를 가진, 함수가 자주 쓰인다.

코틀린 코딩 컨벤션 글에서는 expression body 함수를 사용할 수 있을 때 이를 사용하는 것을 권장하고 있다.

fun foo(): Int {     // bad
    return 1
}

fun foo() = 1        // good

// 한 줄로 쓰기에는 너무 긴 expression body 함수는 줄 바꿈을 한다
fun f(x: String, y: String, z: String) =
    veryLongFunctionCallWithManyWords(andLongParametersToo(), x, y, z)

타입 추론

타입 추론(type inference): 컴파일러가 타입을 분석해 프로그래머 대신 프로그래머 구성 요소의 타입을 정해주는 기능

expression body 인 함수는 컴파일러가 함수의 타입을 추론할 수 있는 경우, 함수의 타입을 생략할 수 있다. 

 

반면, statement body 인 함수는 리턴값이 있다면, 반드시 함수의 리턴 타입을 명시하고,

`return` 문을 사용해서 리턴 값을 명시해야 한다.

만약 아주 긴 함수가 분기에 따라 여러 값을 리턴한다고 하면,

`return`을 반드시 사용하게 해서 어떤 타입의 값을 리턴하고, 어디서 그 값을 리턴하는지를 쉽게 볼 수 있다.

변수

자바에서는 변수를 선언할 때 타입이 변수 앞에 오지만, 코틀린에서는 타입 지정을 변수 뒤에 하며, 생략할 수 있는 경우도 많다.

String question = “삶, 우주, 그리고 모든 것에 대한 궁극적인 질문”
val question: String = “삶, 우주, 그리고 모든 것에 대한 궁극적인 질문”

 

expression body 함수와 마찬가지로, 타입을 명시하지 않으면,

컴파일러가 초기화 식을 분석해서 초기화 식의 타입을 변수 타입으로 지정한다. 

val answer: Int = 42 // 타입 명시한 경우

val answer = 42 // 타입 생략한 부분. 컴파일러가 42 를 읽고 Int 타입으로 추론한다

 

초기화 식을 사용하지 않고 변수를 선언하려면 변수 타입을 반드시 명시해야 한다.

초기화 식이 없다면, 컴파일러가 변수에 대한 정보를 찾아낼 수 없어서 타입 추론이 불가능하기 때문이다.

inferred 타입으로 리턴하지 말라

이 부분은 조금 어려운 내용이다.

타입 추론을 사용할 때는 몇가지 위험한 부분들이 있다.

inferred 타입은 정확히 오른쪽에 있는 피연산자에 맞게 설정된다. (슈퍼 클래스 or 인터페이스로 설정되지 않음)

open class Animal
class Zebra: Animal()

var animal = Zebra() // 타입 추론
animal = Animal() // [Compile Error] Type mismatch: inferred type is Animal but Zebra was expected

var animal: Animal = Zebra() // 타입 명시
animal = Animal() // 성공

보통은 이러한 것이 문제가 되지 않지만, 원하는 타입보다 제한된 타입이 설정되었다면,

타입을 명시적으로 지정해서 이런 문제를 해결할 수 있다.

리턴 타입 명시를 제거해서 생길 수 있는 문제

하지만 직접 라이브러리(또는 모듈)을 조작할 수 없다면, 이런 문제를 간단히 해결할 수 없다.

이 때는 inferred 타입만을 사용하면 위험한 일이 생길 수 있다.

아래 예제를 보자.

// 서버 코드
interface CarFactory {
    fun produce(): Car
}

open class Car()
class Flat126P : Car()
/* 다른 Car 확장하는 클래스들 */

// 클라이언트 코드
val DEFAULT_CAR: Car = Flat126P()

val someCarFactory = object : CarFactory {
    override fun produce(): Car {
        TODO("Not yet implemented")
    }
}

 다른 것을 지정하지 않았을 때 클라이언트에서 디폴트로 `Flat126P` 라는 자동차가 생성된다고 하자.

 

코드를 작성하다 보니 `DEFAULT_CAR` 는 `Car` 로 명시적으로 저장되어 있으므로,

따로 필요없다고 판단하여 함수의 리턴타입을 제거했다고 하자.

interface CarFactory {
    fun produce() = DEFAULT_CAR // 현재까지 아직 리턴타입은 Car
}

// 클라이언트 코드
val DEFAULT_CAR: Car = Flat126P() 

/* ... */

 

그런데 이후에 다른 사람이 코드를 보다가 `DEFAULT_CAR` 는 타입 추론에 의해서 자동으로 타입이 지정될 것이므로, 

`Car` 를 명시적으로 지정하지 않아도 된다고 생각해서 아래처럼 코드를 변경했다고 해보자.

interface CarFactory {
    fun produce() = DEFAULT_CAR // 이제 리턴 타입이 Flat126P 가 된다.
}

val DEFAULT_CAR = Flat126P() // 클라이언트 코드에서 타입 명시 제거

val someCarFactory = object : CarFactory {
    override fun produce(): Car { // [Compile Error] produce 의 리턴 타입은 Flat126 이다
        TODO("Not yet implemented")
    }
}

 이렇게 되면 문제가 발생한다.

이제 `CarFactory` 에서는 오직 `Flat126P` 이외의 자동차를 생산하지 못한다.

 

만약 우리가 인터페이스를 직접 만들었다면 쉽게 문제를 수정할 수 있다.

하지만 만약 외부 API 라면, 문제를 쉽게 해결할 수 없다.

 

리턴 타입은 API 를 잘 모르는 사람에게 전달해 줄 수 있는 중요한 정보이다.

결론적으로, 타입을 확실히 지정해야 하는 경우에는 명시적으로 타입을 지정해주자!!

inferred 타입은 프로젝트가 진전될 때 제한이 너무 많아지거나, 예측하지 못한 결과를 낼 수 있다.

Mutable, Immutable 변수

  • `val` (value 에서 따옴): immutable 한 참조를 저장하는 변수. 자바의 `final`변수에 해당한다.
  • `var`(variable, 형용사로 변할 수 있는, 에서 따옴): mutable 한 참조를 저장하는 변수. 자바의 일반 변수에 해당한다.

기본적으로는 모든 변수를 `val` 키워드를 사용해서 불변 변수로 선언하고, 나중에 꼭 필요할 때만 `var` 로 변경하는 게 좋다.

  • 클래스가 인스턴스 변수로 `var` 변수를 많이 가지고 있는 것은 그 클래스의 객체가 어떠한 변화하는 상태를 가지고 있다는 것이다.
  • 이러한 클래스가 많다면,  상태를 추적하는 것이 어려워져서 프로그램을 이해하고 디버그하기 힘들어진다.
  • 시점에 따라 값이 달라질 수 있으니, 코드의 실행을 추론하기가 어려워지며, 한 시점에 확인한 값이 계속 동일하게 유지된다고 확신할 수 없다.
  • 멀티스레드 프로그램에서는 변경이 일어나는 모든 부분에 충돌이 일어날 수 있고, 동기화가 필요하다.
  • 모든 상태를 테스트해야 하므로, 테스트하기 어려워진다.

반면에, immutable 한 참조와 객체를 side effect 가 없는 함수와 조합해서 사용하면, 코드가 함수형 코드에 가까워 질 수 있다.

 

`val` 변수는 블록 실행 시 딱 한번만 초기화해야 한다.

fun initValOnlyOnce() {
    val message: String
    // ....
    message = if (canPerformOperation()) {
        "Success"
        // ...
    } else {
        "Failed"
    }
}
private fun canPerformOperation(): Boolean {
    // 이 함수 결과에 따라 message 에 초기화되는 문자열을 결정
    return true
}

 

물론, `val` 참조 자체는 immutable 이라도, 그 참조가 가리키는 객체의 내부 값은 변경될 수 있다. 

fun `val 참조가 가리키는 객체의 내부 값은 변경 가능`() {
    val languages = arrayListOf("Java")
    languages.add("Kotlin")
}

 

`var` 키워드를 사용하면 변수의 값은 변경할 수 있지만 변수의 타입은 변경할 수 없다.

컴파일러는 변수 선언 시점의 초기화 식으로부터 변수의 타입을 추론한다. 

 

만약 변수에 다른 타입의 값을 저장하고 싶다면, 그 값을 변환 함수를 써서 값의 타입을 변환하거나 강제 형변환(coerce) 해야 한다.

 

문자열 템플릿 (string templates)

변수를 문자열 안에 사용할 수 있다. 문자열 리터럴의 필요한 곳에 변수를 넣되 변수 앞에 `$` 를 추가해야 한다.

`${someList.toString}` 처럼 중괄호를 이용해서 복잡한 식도 문자열 템플릿으로 사용할 수도 있다.

fun `문자열 템플릿`(args: Array<String>) {
    val name = if (args.size > 0) args[0] else "Kotlin"
    println("Hello, $name!")
    // "Hello, " + name + "!" 보다 훨씬 간결하다.
}

fun main(args: Array<String>) {
    MutableAndImmutable().`문자열 템플릿`(args)
}

 

`$` 자체를 문자열에 넣고 싶다면, 이스케이프 문자(`\`) 를 사용하여  `"\$"` 의 형태로 사용해야 한다.

 

 

참조

https://kotlinlang.org/docs/coding-conventions.html#expression-bodies