ABOUT ME

Today
Yesterday
Total
  • [ Effective Kotlin ] Chapter 2 : Readability
    Kotlin 2024. 2. 7. 16:33

    Item 11 : Design for readability

    '디버깅 작업으로 코드를 추적하고 읽는 시간' vs '새로운 API 로직과 구현 방법을 이해하기 위해 코드를 읽는 시간'

    개발자들은 위 두 가지를 비교해 보면, 프로그래밍은 코드 작성 시간보다 코드를 읽는 시간이 더 길다는 것을 알 수 있다. 

     

    따라서 코드 작성 시, 'Readability'를 고려하여 코드를 명확하게 작성하는 것이 중요하다.

    Reducing cognitive load

    'Readability'는 개발자마다 다르게 해석될 수 있다. 아래 예시를 보자.

    // impl A
    if (person != null && person.isAdult) {
        view.showPerson(person)
    } else {
        view.showError()
    }
    
    // impl B
    person?.takeIf { it.isAdult }
        ?.let(view::showPerson)
        ?: view.showError()

     

    경험이 적은 Kotlin 개발자는 A 구현이 훨씬 더 읽기 쉬울 것이고, 경험이 많은 Kotlin 개발자들은 B 구현이 더 익숙할 수 있다. 이처럼 두 구현의 'Readability'는 개발자들이 구문을 인식하고 이해하는데 얼마나 훈련되어 있는지에 따라 달라진다.

     

    'Readability' 관점에서는 A 구현이 더 장점이 많으며, 그 이유는 아래와 같다.

    1. 프로젝트 협업 시, 주니어 개발자는 B 구현을 이해하는데 비효율적인 시간 낭비할 수 있으며, Kotlin을 오랫동안 사용했더라도 일반적으로 사용되는 A 구현을 이해하는 것이 더 쉽다.
    2. 디버깅 도구는 A 구현과 같이 기본적인 구조에 맞춰서 설계되었기에, 디버깅 과정도 B 구현에 비해 더 간단하다.
    3. 추가적인 조건을 분기해야 하는 경우, A 구현에서는 'when' 구문으로 전환하여 추가적인 분기를 쉽게 추가할 수 있다.
      반면, B 구현은 이와 같은 변경이 매우 어려워, 코드를 전면적으로 다시 작성해야 할 수 있다.
    4. 'if' 블록에 추가적인 구현이 필요한 경우, A 구현의 변경이 더 용이하다. 반면, B 구현의 경우에 '함수 참조' 사용이 불가능하며, 엘비스 연산자의 오른쪽 항에 특정 함수('run')를 사용해야 한다. 또한, 'showPerson'이 'null'을 반환하는 경우에 'let' 함수가 결과를 반환함으로써, 그다음 로직 'run'을 실행하게 된다. 이로 인해 'showError'가 호출될 수 있어 원하지 않는 동작이 실행될 수 있고, 이런 원하지 않는 상황들이 코드 리뷰나 디버깅 시 쉽게 간과될 수 있다.
    if (person != null && person.isAdult) {
        view.showPerson(person)
        view.hideProgressWithSuccess()
    } else {
        view.showError()
        view.hideProgressBar()
    }
    
    person?.takeIf { it.isAdult }
      ?.let {
        view.showPerson(it)
        view.hideProgressWithSuccess()
      } ?: run {
        view.showError()
        view.hideProgressBar()
      }

    Do not get extreme

    그럼에도, 'let'은 다양한 상황에서 코드를 간결하게 만들 수 있는 인기 있는 구문이다.

     

    예를 들어, 'Nullable 가변 프로퍼티'가 'Non-Null' 일 때 어떤 연산을 실행해야 하는 경우, 'Safe-call'과 'let'을 함께 사용하여 간결하게 작업할 수 있다.

    var name: String? = null
    
    fun printName() {
        name?.let { println(it) }
    }

     

    또한 'let'은 아래와 같은 상황에서도 간결하게 코드를 작성할 수 있다.

    • 'argument'에 대한 계산을 끝낸 후, 특정 연산을 수행하고 싶은 경우
    students
        .filter { it.pointesInSemester > 15 && it.result >= 50 }
        .sortedWith(compareBy({ it.surname }, { it.name }))
        .joinToString(separator = "\n") { "${it.surname} ${it.name}, ${it.result}" }
        .let(::print)
    • 객체를 'Decorator 패턴'으로 래핑 하고 싶은 경우
    val obj = FileInputStream("/file.gz")
      .let(::BufferedInputStream)
      .let(::ZipInputStream)
      .let(::ObjectInputStream)
      .readObject() as SomeObject

     

    위와 같은 코드들은 디버깅이 어렵고, Kotlin 경험이 부족한 개발자들이 이해하기에 어려운 코드일 수 있다.
    하지만, Kotlin에 익숙한 개발자들은 이런 기능과 구조가 가독성이 높고, 프로그램 동작 원리를 더 빠르게 이해할 수 있다.

     

    이처럼 개발자는 코드 설계 시, 코드 복잡성과 가독성 사이의 균형을 항상 고려해야 한다. 복잡성을 올리는 것은 코드 이해와 유지 관리를 어렵게 만들 수 있지만, 특정 상황에서 코드 효율성과 표현력을 높일 수 있다.

    따라서 복잡한 구조를 사용하는 것은 개발자 판단에 달렸으며, 항상 합리적인 이유를 가지고 사용해야 한다.

    Convention

    위에서 'Readability'는 개발자 경험에 따라 다를 수 있음을 확인하였다.

     

    개발자들은 이런 경험의 차이를 최소화하기 위해서 컨벤션을 정하고 이를 따르는 것이 좋다.

    아래는 Kotlin에서 좋지 않은 컨벤션을 사용한 예시이다.

    operator fun String.invoke(f: ()-> String): String = this + f()
    infix fun String.and(s:String) = this + s
    
    val abc = "A" { "B" } and "C"
    print(abc) // ABC

     

    위 코드는 다음과 같은 컨벤션을 위반한다.

    • 'invoke' 연산자는 객체를 함수처럼 호출하는 연산자로써, 문자열에서 이 연산자를 사용하는 것은 맥락에 맞지 않다.
    • 함수 마지막 파라미터가 람다 표현식인 경우, 괄호 밖에 람다를 작성할 수 있는 'Syntactic sugar'를 제공하는데, 위 코드에서는 'invoke'와 함께 사용되어 혼란을 줄 수 있다.
    • 'and'라는 함수명에 대해 의미가 명확하지 않으며 기능을 직관적으로 이해하기 어렵다. 'append' 또는 'plus'와 같이 더 명확한 이름을 통해 메서드의 의도와 기능을 명확하게 표현할 수 있었을 것이다.
    • 문자열 연결에 관한 함수('append')는 이미 언어에서 제공하기에 새로운 방법을 만들기보단, 기존의 방법을 사용하는 것이 좋다.

    Item 12 : Operator meaning should be consistent with its function name

    'Operator overloading'은 Kotlin의 강력한 기능 중 하나로, 특정 객체의 연산자 동작 방식을 재정의하는 기법이다.

     

    Kotlin에서 각 연산자는 구체적인 의미를 갖기에, 처음 사용해도 각 연산자 의미를 쉽게 이해할 수 있는 장점이 있다.

    예를 들어, x + y == z라는 표현식은 x.plus(y).equals(z)와 동일하다는 것을 바로 알 수 있고,

    plus가 'Nullable 타입'이라면 x.plus(y))?.equals(z) ?: (z == null)과 같음을 바로 알 수 있다.

     

    이처럼 각 연산자는 항상 일정한 의미를 갖는 것이 중요한 설계 원칙 중 하나이기에, 'Operator overloading'을 사용하는 것은 지향하는 것이 좋다. 만약 이런 컨벤션을 지키지 않고, 연산자를 '특정 컨텍스트' 또는 '수학적 이론의 의미'로 오버로딩 한다면, 함수와 클래스의 이름에 명확한 의미가 존재하여도 내부 코드를 이해하는데 어려움을 겪을 수 있다.

    Unclear cases

    가장 큰 문제점은 특정 기능이나 문법이 컨벤션을 따르고 있는지 불확실 할 때 이다.

    예를 들어, "함수를 3배로 확장한다."를 어떤 의미로 생각하는지 사람들에게 물어보면 다음과 같이 나뉠 것이다.

    • 주어진 함수를 3번 '반복 실행'하는 새로운 함수를 만든다.
    • 주어진 함수를 3번 '호출'하는 새로운 함수를 만든다.

    이처럼 의미가 명확하지 않은 경우, 확장 함수의 이름을 더 명시적으로 설명하듯 작성하는 것이 좋다.
    추가로, 함수 사용을 연산자처럼 유지하고 싶으면 'infix function'으로 정의하여 사용할 수 있다.

    infix fun Int.timesRepeated(operation: () -> Unit) = {
        repeat(this) { operation() }
    }
    
    val tripledHello = 3 timesRepeated { print("Hello") }
    tripledHello() // HelloHelloHello

     

    만약, 이미 표준 라이브러리에서 제공되고 있다면, 제공되는 함수를 사용하는 것이 더 바람직하다.

    repeat(3) { println("Hello") } // HelloHelloHello

    Item 13: Avoid returning or operating on Unit?

    'Unit?'은 'Unit' 또는 'null'로 두 가지 상태를 가질 수 있다.

    이는 'Boolean'과 같은 동치성을 갖기에 'Unit?'과 'Boolean'을 비슷하게 사용 하려는 실수를 범할 수 있다.

     

    만약 'Unit?'을 논리적인 값으로 사용하면, 코드를 이해하는데 혼란을 줄 수 있고, 발견하기 어려운 오류로 번질 수 있다.

    getData()?.let { view.showData(it) } ?: view.showError()

     

    위 코드를 보면, 개발자들은 'getData' 실행 후, 'showData' 또는 'showError' 중 하나만 실행하도록 예상하여 구현할 것이다. 하지만, 'showData'에서 'null'이 반환되면 'showData'와 'showError'가 모두 호출된다.

     

    이처럼 'Unit?' + 'Elvis 연산' + 'Safe-call'을 사용하는 경우 의도치 않은 결과를 초래할 수 있고, 가독성까지 떨어뜨릴 수 있다. 이를 피하기 위해 'if-else' 구문을 통해 더 명확한 결과와 가독성을 챙길 수 있다.

    val person: Person? = getPerson()
    
    if (person != null) {
        view.shoePerson(person)
    } else {
        view.showError()
    }

     

    또 다른 예시로, 아래 두 구현은 기능적으로 유사하지만, 가독성과 명확성 측면에서 차이가 있다.

     

    아래 코드는 'Boolean'으로 논리적 조건을 명확하게 표현하며, 키 값이 올바른지 직접적으로 검사하고, 그 결과에 따라 동작을 수행한다. 그리고, 'if'문은 프로그래밍에서 널리 사용되는 조건 처리 방식으로 대부분의 개발자들에게 익숙하며 직관적임을 알 수 있다.

    fun keyIsCorrect(key: String): Boolean = // ...
    if (!keyIsCorrect(key)) return

     

    반면, 아래 코드는 위 구현보다 덜 직관적이고, 'Unit?'의 의미와 사용법에 익숙하지 않은 개발자들은 이해하는데 어려움을 겪을 수 있다. 이처럼 'Unit?'은 가독성 향상에 있어 좋은 옵션이라고 볼 수 있는 사례가 거의 없으며, 혼란을 줄 수 있기에 'Boolean'으로 대체하는 것이 좋다.

    fun verifyKey(key: String): Unit? = // ...
    verifyKey(key) ?: return

    Item 14 : Specify the variable type when it is not clear

    Kotlin은 타입이 명확한 경우에 타입 명시를 생략할 수 있는 '타입 추론 시스템'을 지원한다.

    이처럼 문맥상 타입이 명확한 경우, 'inferred type'을 통해 코드를 깔끔하게하고 가독성을 높일 수 있다.

     

    그러나, 타입이 명확하지 않는 상황에서 타입 명시를 생략하면 아래와 같이 문제가 될 수 있는 상황이 발생한다. 

    • 리턴 타입이 생략된 A 함수를 B 함수 안에서 타입을 추론하고 있다면, 해당 A 함수의 리턴 타입을 확인하기 위해 타입을 추적하는 상황이 발생되어 불필요한 비용이 발생하게 된다.
    • Github 환경과 같이 함수 구현점으로 점프할 수 있는 기능을 지원하지 않는 상황에서, 타입 명시가 되어 있지 않는 코드를 확인하고 있다면 리뷰어는 해당 코드를 확인하기 위해 불필요한 비용이 발생하게 된다.

    이처럼 타입 명시는 중요한 역할과 정보를 제공하므로, 타입이 명확하지 않다면 반드시 명시되어야 한다.
    또한 타입 명시는 아래와 같이 코드 안전성과 유지보수성을 높이는데 도움이 된다.

    • 타입 명시는 API 사용자에게 필요한 타입 정보를 명확하게 제공 할 수 있도록 한다.
    • 컴파일러가 더 엄격한 타입 검사를 수행하여 런타임 오류 가능성을 줄일 수 있다.
    • 코드 의도를 더 명확하게 표현하여 가독성과 이해도를 높일 수 있다.

    Item 15 : Consider referencing receivers explicitly

    클래스 내부에서 해당 클래스의 인스턴스 또는 프로퍼티, 확장 함수 내부에서 리시버 객체를 참조할 때, 'this' 키워드를 사용하면 명시적으로 참조하고 있다고 표현할 수 있다.

    class User: Person() {
       private var beersDrunk: Int = 0
    
       fun drinkBeers(num: Int) {
          this.beersDrunk += num  // 명시적으로 리시버 참조
       }
    }
    
    fun <T: Comparable<T>> List<T>.quickSort(): List<T> {
        if (size < 2) return this
        val pivot = this.first()  // 명시적으로 리시버 참조
        val (smaller, bigger) = this.drop(1).partition { it < pivot } // 명시적으로 리시버 참조
        return smaller.quickSort() + pivot + bigger.quickSort()
    }

    Many receivers

    만약, 하나의 코드 블록 내에서 여러 리시버 객체에 대한 접근이 필요할 때, 이를 명시적으로 구분하지 않으면 어떤 객체의 메서드나 프로퍼티에 접근하는지 구분하기 어려워진다. 특히, 'apply', 'with', 'run', 'also'와 같은 'Scope function'을 사용할 때 더 혼란스러워진다.

     

    'Scope function' 내부에서 'this'는 'Scope function'을 호출한 객체를 참조하게 되는데,

    이 때 만약 'Scope function'이 중첩되어 있다면 'this'가 어떤 객체를 참조하는지 명확하지 않아 코드를 이해하기 어렵다.

     

    이런 상황에서는 리시버를 명시적으로 참조하면 명확하게 코드 의도를 표현할 수 있게 된다.

    아래 코드는 'also'를 통해 리시버를 명시적으로 사용하여 추가적인 작업을 하는 적절한 예시이다.

    class Node(val name: String) {
    
        fun create(name: String): Node? = Node(name)
    
        fun makeChild(childName: String) =
            create("$name.$childName")
                .also { createNode -> 
                	print("Created ${createNode?.name}")
                }
    }

     

    추가로 여러 수준의 리시버를 참조하는 경우, 'label'을 통해 명시적으로 구분할 수 있으며 다음과 같이 사용된다.

     

    'label'이 없는 리시버는 가장 가까운 'Scope function'의 리시버를 참조한다.
    반대로, 외부 리시버에 접근이 필요한 경우, 'label'을 통해 명시적으로 리시버를 참조할 수 있다.

    class Node(val name: String) {
    
        fun create(name: String): Node? = Node(name)
    
        fun makeChild(childName: String) =
            create("$name.$childName").apply { 
                print("Created ${this?.name} in ${this@Node.name}")
            }
    }
    
    fun main(){
        val node = Node("parent") 
        node.makeChild("child") // Created parent.child in parent
    }

    Item 16 : Properties should represent state, not behavior

    Kotlin 프로퍼티는 'getter•setter'를 통해 데이터를 읽고 쓰는 기능을 제공하며, 다음과 같이 사용할 수 있다.

    var name: String? = null
        get() = field?.uppercase()
        set(value) {
            if (!value.isNullOrBlank()) {
                field = value
            }
        }

     

    위 코드에서 'setter'에 'field' 식별자를 사용하는데, 이는 프로퍼티 값을 저장할 수 있게 해주는 'Backing field'를 참조한다.

     

    'Backing field'는 프로퍼티 값을 저장하기 위해 숨겨진 필드로, 'getter•setter'가 값에 접근할 때 Kotlin 컴파일러가 자동으로 생성한다. 만약, 'getter•setter'가 'field' 식별자를 사용하지 않으면 'Backing field'는 생성되지 않는다.

     

    이와 같이 'Backing field'가 없다는 것은, 아래 예시 코드와 같이 프로퍼티가 직접 값을 저장하지 않고 다른 방식으로 값을 제공하거나 계산되어 사용됨을 의미한다.

    val fullName: String
        get() = "$firstName $lastName"
    
    var dateVariable: Date
        get() = Date(millis)
        set(value) {
            millis = value.time
        }

     

    이처럼 'Backing field'가 없는 프로퍼티를 'Derived properties'라고 한다.

     

    'Derived properties'는 내부적으로 다른 프로퍼티나 상태에 기반한 값을 생성할 수 있고, 외부에서는 단순하게 프로퍼티에 접근하여 값을 얻을 수 있다. 이는 내부 상태를 숨기고, 외부에서는 객체와 상호작용 할 수 있는 제한된 인터페이스('getter')만 제공함으로써 캡슐화 원칙을 준수할 수 있다.

     

    이로 인해, Kotlin에서는 모든 프로퍼티가 기본적으로 '캡슐화' 될 수 있다.

     

    좀 더 예시를 들어보면, 어떤 객체에서 'Date' 타입을 통해 날짜 사용하고 있다가, 직렬화의 이유로 더 이상 사용할 수 없는 상황이 발생이 발생한 경우 Kotlin에서는 이런 변경사항을 더 유연하게 대응 할 수 있다.

     

    'Date' 타입을 'Derived properties'로 사용하고, 직렬화에 필요한 날짜 데이터를 내부에서만 사용하도록 작성하여, 코드 수정을 최소화로 새로운 요구사항을 적용할 수 있다.

    class Article {
        private var dateInMillis: Long = System.currentTimeMillis()
    
        val date: Date
           get() = Date(dateInMillis)
    }

     

    이처럼, 모든 프로퍼티가 'Backing field'를 갖는 것은 아니며, 'Backing field'가 없는 프로퍼티는 데이터에 접근하는 방법으로 사용된다. 이러한 특성으로 인해, 상태를 저장할 수 없는 인터페이스에서도 프로퍼티를 사용할 수 있다.

     

    아래와 같이 인터페이스 내에 선언된 프로퍼티는 구현 클래스가 해당 프로퍼티의 'getter'를 구현해야 함을 명시하는 'contract 역할'을 한다.

    interface Person {
        val name: String
    }

     

    위와 동일한 이유로 프로퍼티를 'override' 하거나, 'delegation' 할 수 있다.

    // e.g : override
    open class SuperComputer {
        open val theAnswer: Long = 42
    }
    
    class AppleComputer: Supercomputer() {
        override val theAnswer: Long = 1_800_275_2275
    }
    
    // e.g : delegate pattern
    val db: DataBase by lazy { connectToDb() }

     

    앞서 프로퍼티에 접근 시, 'setter•getter'가 호출됨을 알 수 있고, 이는 결국 프로퍼티 접근이 '함수 호출'로 이루어짐을 알 수 있다. 이는, '프로퍼티에 대한 접근은 함수 호출'로 볼 수 있으며, 이 점을 고려해 아래와 같이 'Extension properties'를 사용할 수 있다.

    val Context.preferences: SharedPreferences
        get() = PreferenceManager.getDefaultSharedPreferences(this)
    
    val Context.notificationManager: NotificationManager
       get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

     

    프로퍼티에 대한 접근을 함수 호출로 볼 수 있다면, 프로퍼티의 'setter•getter'는 특정 함수들을 대체하여 사용될 수 있음을 알 수 있다. 하지만, 프로퍼티 사용의 일반적인 규칙은 상태를 나타내거나 설정하는 것에만 한정되어야 하고, 그 외의 로직은 포함되어선 안된다.

     

    만약 프로퍼티와 함수 중 어떤 것으로 구현해야 하는지 고민이 된다면, 아래와 같은 기준에서는 함수로 구현하는 것이 좋다.

    1. '계산 복잡도 O(1)' 보다 높거나, 비용이 많이 드는 연산일 경우 :
      함수는 명시적으로 개발자에게 비용이 많이 드는 연산임을 알려줄 수 있고, 캐싱을 고려하거나, 덜 사용하도록 유도할 수 있다.
    2. 비지니스 로직을 포함하는 경우 :
      프로퍼티는 로깅, 리스너 알림, 바인딩된 요소의 업데이트 등 간단한 작업에 사용되어야 하며, 그 이상의 복잡한 비지니스 로직은 함수로 정의하는 것이 좋다.
    3. 연속 호출 시, 다른 결과를 반환하는 경우 :
      프로퍼티는 동일한 입력에 대해 항상 동일한 출력을 반환하는 것이 일반적인 원칙이다.
      만약, 어떤 프로퍼티의 값이 외부 상태에 의존하거나 내부 상태를 변경함으로써, 호출 후 결과가 매번 다르다면, 이는 프로퍼티 사용의 부적절한 사례이다. 이러한 로직은 함수로 구현하는 것이 좋다.
    4. 실행 순서가 동작에 영향을 미치는 경우 :
      프로퍼티 값이 설정되거나 조회되는 순서가 앱의 동작에 영향을 미치는 경우, 프로퍼티 사용의 부적절한 사례이다.
      프로퍼티는 독립적으로 동작하고, 'A 프로퍼티' 설정이 다른 'B 프로퍼티'의 동작에 직접적인 영향을 주지 않는 것이 기본 원칙이다. 만약 실행 순서가 동작에 영향을 미치는 경우, 이는 함수로 구현하는 것이 좋다.
    5. 'Int.toDouble'과 같이 타입 변환이 필요한 경우 :
      타입 변환은 객체 전체의 변환 또는 연산을 수행하는 과정이므로, 일반적으로 함수로 수행되는 것이 컨벤션이다.
      만약, 프로퍼티를 통해 타입 변환을 구현하게 되면, 이는 단순하게 객체 내부의 상태에 접근하는 것처럼 보일 수 있어 혼란을 줄 수 있기에 타입 변환은 함수로 구현하는 것이 좋다.
    6. 'getter'가 프로퍼티 상태를 변경하는 경우 :
      프로퍼티의 'getter'는 현재 값을 조회하는 용도로 사용되어야 하며, 상태를 변경하거나 Side-effect를 발생시켜선 안된다. 'getter'가 상태를 변경시키는 방식은 코드 가독성과 안정성, 캡슐화를 저해할 수 있으며, 이러한 경우에는 함수로 구현하는 것이 좋다.

    Item 17 : Consider naming arguments

    아래 코드에서 'joinToString'에 익숙한 개발자라면, `|`가 'separator'로 사용될 것이라는 것을 쉽게 알 수 있다.
    하지만, 익숙하지 않은 개발자라면 'prefix'로 이해할 수 있기 때문에, 해당 'argument'가 어떤 목적으로 사용되는지 혼란스러울 수 있다.

    val text = (1..10).joinToString("|")

     

    위와 같이 'argument'의 의미가 명확하지 않은 경우, 'named-argument'를 통해 'argument'의 목적을 명확하게 표현할 수 있다.

    val text = (1..10).joinToString(separator = "|")

     

    'named-argument'는 코드를 더 길게 만들지만, 어떤 것을 예상 값으로 사용하는지 명확하게 표현이 가능하다.

    또한 'named-argument'는 함수의 사용이나, 함수의 호출 방식을 이해하는데 도움을 준다.


    아래 첫 번째 함수 호출만 보고선, 100ms 동안 슬립하는 것인지, 100s 동안 슬립하는 것인지 알 수 없다.
    하지만, 'named-argument'를 사용하면 이를 더 명확하게 표현할 수 있다.

    sleep(100)              // 100ms??  or  100s??
    sleep(millis = 100)     // 100ms  

     

    Kotlin에서는 코드 명확성을 높이는 방법이 'named-argument' 말고도 하나 더 존재 한다.
    바로 'argument' 전달 시, 파라미터 타입을 통해 함수 호출 의도를 명확하게 전달하고, 코드 명확성을 높이는 것이다.

    sleep(Milliseconds(100))

     

    하지만, 타입만으로 'argument'의 역할이나 순서의 명확성을 보장하기 어려운 상황이 생길 수 있다.
    이로 인해, 아래와 같은 파라미터를 가진 함수를 호출할 때에는 'named-argument'를 사용하는 것이 좋다.

    1. Parameters with default arguments

    기본값을 가진 파라미터는 함수 호출 시 해당 파라미터에 대해 명시적으로 값을 제공하지 않아도 괜찮다.
    이는 개발자가 해당 파라미터에 값을 제공하지 않으면, 함수에 명시된 기본값이 자동으로 사용됨을 의미한다.

     

    하지만, 이러한 'optional 파라미터'는 시간이 지남에 따라 아래와 같은 변경사항이 발생할 확률이 높다.

    1. 기존에 사용되던 기본값 변경
    2. 새로운 'optional 파라미터'가 추가되어, 파라미터 순서 변경

    이처럼 함수에 변경사항이 생겼을 때, 해당 함수를 호출하는 코드를 모두 찾아서 변경사항을 반영해야 한다.
    만약, 변경사항을 제대로 반영하지 않으면, 의도한 대로 함수가 동작하지 않을 수 있다.

     

    사전에 'named-argument'를 사용하여 함수를 호출하고 있었으면, 위와 같은 변경사항이 발생하여도 함수 호출 코드를 거의 수정할 필요가 없기에 더 유연하게 대응 할 수 있다. 

    2. Many parameters with the same type

    서로 다른 타입의 파라미터에 잘못된 순서로 값을 전달하면, 컴파일러가 타입이 불일치하다는 오류 메시지를 표시하기에 안전하다. 그러나 파라미터 타입이 같을때에는 컴파일러가 오류 메시지를 표시하지 않기에 실수를 방지하기 어렵다.

    fun sendEmail(to: String, message: String) { /* ... */ }

     

    위와 같은 상황에서는 'named-argument'를 사용하여, 각 파라미터의 역할을 명확하게 구분하여 실수를 방지하는 것이 좋다.

    sendEmail(
        to = "email@email.com",
        message = "Mail content"
    )

    3. Parameters of function type

    마지막 위치에 함수형 파라미터가 있으면 람다 표현식으로 처리된다.
    그리고 때때로 '람다 표현식' 또는 '함수가 수행할 작업의 성격'을 직관적으로 반영하여 함수의 이름으로 사용하는 경우가 있다.

     

    예를 들어, 'repeat'은 람다 표현식이 반복될 코드 블록으로 사용됨을 직관적으로 알 수 있고,
    'thread'는 새로운 스레드에서 실행될 코드 블록으로 사용될 것임을 직관적으로 알 수 있다.

     

    그러나, 함수형 파라미터가 여러개 있는 경우에는 각각의 역할이 혼동 될 수 있기에 'named-argument'를 사용하는 것이 좋다.

    fun call(
       before: () -> Unit = { },
       after: () -> Unit = { }
    ) {
       before()
       println("Calling")
       after()
    }
    
    // Don't do this
    call({ print("CALL") }) // CALLCalling
    call { print("CALL") } // CallingCALL
    
    // OK
    call(before = { print("CALL") }) // CALLCalling
    call(after = { print("CALL") }) // CallingCALL

    Item 18 : Respect coding conventions

    Kotlin은 코딩 컨벤션을 문서로 제공하여, 개발자들이 일관된 코드를 작성할 수 있도록 도와준다.
    이를 프로젝트에 적용하면 아래와 같은 이점을 얻을 수 있다.

    • 공통된 코드 스타일을 유지하며, 프로젝트 전환 시 코드를 좀 더 쉽게 이해할 수 있다.
    • 오픈 소스 프로젝트 또는 외주 개발자가 프로젝트에 참여할 때, 코드를 쉽게 이해할 수 있어 협업이 원활해 진다.
    • 코드 동작에 대한 이해도를 높일 수 있고, 구현 의도를 명확하게 파악하기 쉽다.
    • 다른 프로젝트나 저장소에서 병합 시 충돌을 최소화하며, 코드 특정 부분을 다른 프로젝트로 이식하기 용이해진다.

    이러한 이점이 있기에, Kotlin 개발자들은 컨벤션에 익숙해져야 하며, 컨벤션이 변경되더라도 적극적으로 적응해야 한다.
    물론, 이를 따르는 것이 쉽지 않기에 다음 2가지 도구로 쉽게 준수할 수 있다.

    • IntelliJ 포맷터를 통해, 코드를 일관된 스타일로 자동으로 포맷팅 할 수 있다.
      • Settings → Editor → Code Style → Kotlin → Set from…
    • ktlint 또는 detekt와 같은 정적 분석 도구를 사용하여, 컨벤션을 준수하는지 확인할 수 있다.

    추가로, Kotlin 컨벤션 중 아래와 같이 클래스와 함수의 포맷팅 방식이 달라 혼란스러울 수 있다.

    • 짧은 'primary-constructor'는 1줄로 정의될 수 있다.
    • 파라미터가 많은 클래스는 첫 번째 줄에 파라미터가 없어야 하고, 각 파라미터가 다른 줄에 오도록 포맷팅 되어야 한다.
    class FullName(val name: String, val surname: String)
    
    class Person(
        val id: Int = 0,
        val name: String = "",
        val surName: String = ""
    ): Human(id, name) { 
        // body
    }

     

    그러나 위 두 가지 방식 모두 컨벤션에 맞는 방식이므로, 상황에 따라 혹은 팀원끼리 합의하여 일관된 방식으로 사용하는 것이 좋다.

     

    이처럼 회사마다 컨벤션을 약간씩 수정하여 적용하는 것이 허용되지만, 모든 프로젝트가 한 사람이 작성한것처럼 보이도록 일관되게 컨벤션을 적용해야 하는 것을 명심해야 한다.

Designed by Tistory.