본문 바로가기

Kotlin

공식 문서로 배우는 Kotlin - 3. Basics - Coding conventions

세번째, 코딩 규칙입니다.

 

프로그래밍을 할 때 일관되고 쉬운 코딩 규칙을 따르는 것은 여러모로 중요합니다. 예전에는 같이 협업하는 개발자들끼리 서로 상의하여 정해 놓고 쓰기도 했지만, (대표적으로 Python의 PEP-8처럼) 요즘 언어들은 코딩 규칙을 공식 문서에서 제공하기 때문에 비교적 쉽게 공통적인 코딩 규칙을 따를 수 있습니다. 코딩 규칙을 따르게 되면 일관된 규칙으로 가독성을 높이고 인적 실수를 줄일 수 있기 때문에 특정 언어를 처음 배우는 경우라면 반드시 코딩 규칙을 (적어도 한 번 이상) 읽어 보는 것이 좋습니다.

 

소스 코드 구성(organization)

디렉토리 구조

순수한 Kotlin 프로젝트에서 권고되는 디렉토리 구조는 공통 루트 패키지를 생략한 패키지 구조를 따르는 것입니다. 예를 들어, 프로젝트의 모든 코드가 org.example.kotlin 패키지와 그 하위 패키지에 속한다면, org.example.kotlin에 속하는 파일들은 바로 소스 루트 디렉토리에 위치 시킵니다. 그리고, org.example.kotlin.network.socket 패키지에 속하는 파일들은 루트 디렉토리의 network/socket 서브 디렉토리에 놓습니다.

JVM의 경우: 프로젝트에서 Kotlin과 Java가 같이 사용되는 곳에서는 Kotlin 소스 파일은 반드시 Java 소스 파일과 동일한 루트에있어야 합니다. 그리고, 그 하위로 동일한 디렉토리 구조를 유지해야 합니다. 각각의 파일들은 해당 패키지에 상응하는 디렉토리에 저장돼야 합니다.

간결함(concise)을 추구하는 Kotlin에 잘 어울리는 규칙이기는 하지만 JVM 하에서 kotlin을 사용하는 경우 Java와 같이 안 사용하는 경우는 거의 없기 때문에 따르기 어려운 이 규칙입니다. Kotlin은 파일내의 패키지 정의대로 파일이 위치하지 않아도 되는 속성 덕분에 해 볼 수 있는 간결함 중에 하나구나 하고 넘어가도 괜찮지 않을까 생각됩니다.

 

소스 파일 이름

Kotlin 파일이 하나의 클래스나 인터페이스(그리고 그와 관련된 최상위 수준 선언)만을 포함하고 있는 경우, 해당 파일 이름은 클래스명과 동일해야 하며 확장자는 .kt 여야 합니다. 이 규칙은 모든 유형의 클래스와 인터페이스에 동일하게 적용됩니다. 파일에 복수의 클래스가 있거나, 또는 오로지 최상위 수준(top level)의 선언들만 있는 경우에는 파일이 포함하고 있는 것을 나타낼 수 있는 이름을 하나 정해서 파일 이름으로 사용합니다. (파스칼 케이스라고 알려진) 첫글자를 대문자로 사용한 카멜 케이스를 사용합니다. 예를 들면, ProcessDeclarations.kt와 같습니다.

 

Java에서는 보통 클래스 하나에 파일 하나를 사용하기 때문에 파일 이름이 클래스 이름과 상응하게 단수로 짓는 경우가 대부분입니다. 하지만, 코틀린에서는 (추후 다시 나오겠지만) 관련된 클래스들을 하나의 파일에 묶는 것도 권장하고 있고, 최상위 수준의 선언(변수나 함수)들을 하나의 파일로 묶는 경우들이 있기 때문에 그런 경우에는 상황에 적합하게 파일 이름을 복수로 지정하는 것도 자연스럽습니다.

파일 이름은 파일 안의 코드가 행하는 것을 나타내야 합니다. 그러므로, 파일 이름에서 Util 같이 의미 없는 단어는 사용하지 말아야 합니다.

이름 짓는 규칙 종류 몇가지

Camel case
낙타 모야을 닮았다고 하여 붙여진 이름.  이름이 여러단어로 이루어진 경우, 첫 단어는 모두 소문자로 쓰고 두번째 단어 부터는 첫 문자를 대문자로 씁니다. 첫 단어의 첫 문자도 대문자로 쓰는 경우를 첫 문자가 대문자인  카멜 케이스라고 부르거나 아래처럼 파스칼 케이스라고 부릅니다.
예) processDeclarations

Pascal case
첫 문자가 대문자인 카멜 케이스. 파스칼이라는 프로그래밍 언어에서 유래하여 붙여진 이름입니다.
예) ProcessDeclarations

snake case
단어 사이를 밑줄로 구분하는 방법. 문자를 모두 대문자를 하는 경우는 특별히 스크리밍 스네이트 케이스(screaming snake case)라고 부릅니다.
예) process_declarations, PROCESS_DECLARATIONS

 

멀티플랫폼 프로젝트

멀티플랫폼 프로젝트에서 플랫폼별 소스 세트의 최상위 수준의 선언이 있는 파일에는 소스 세트 이름과 연결된 접미사가 있어야 합니다. 예를 들면 다음과 같습니다.

  • jvmMain/kotlin/Platform.jvm.kt
  • androidMain/kotlin/Platform.android.kt
  • iosMain/kotlin/Platform.ios.kr

공통 소스 셋에 속하는 최상위 수준 선언이 포함되 파일에는 commonMain/kotlin/Platform.kt 처럼 접미사를 붙이지 않습니다.

 

소스 파일 구성

의미적으로 밀접한 관련이 있고, 모두 합쳐도 적절한 크기(수 천 라인 이내)인 복수의 선언들(클래스들, 최상위 수준 함수들, 프로퍼티들)은 하나의 파일에 위치시키는 것을 권장합니다.

 

특히, 클래스의 확장 함수를 추가할 때 해당 클래스를 사용하는 모든 사용처와 연관된 함수인 경우 클래스와 같은 파일에 담습니다. 특정 사용처에서 사용되는 확장 함수인 경우에는 해당 사용처에 위치 시킵니다. 특정 클래스를 위한 모든 확장 함수를 담기위해 별도의 파일을 만드는 것은 지양해야 합니다.

 

클래스 레이아웃

클래스의 내용은 다음과 같은 순서로 배치합니다.

  1. 프로퍼티 선언과 초기화 블록
  2. 이차 생성자
  3. 메소드 선언
  4. 동반 객체 (Companion Object)

메소드 선언들을 알파벳 순이나 가시성 순으로 정렬하지 말아야 합니다. 그리고, 확장 메소드를 일반적인 메소드와 분리해서는 안 됩니다. 대신에, 다른 사람이 클래스를 위해서 부터 아래로 읽게 될 때, 로직들이 어떤 작업을 하는지 따라갈 수 있도록 연관된 것들을 같이 묶어 놓습니다. 순서를 선택하고(더 높은 수준의 항목을 먼저 또는 그 반대로) 이를 일관되게 지키는 것이 좋습니다.

 

중첩 클래스는 해당 클래스를 사용하는 코드 옆에 위치 시킵니다. 중첩 클래스가 내부에서 사용되지 않고 외부에서 사용될 것이라면 동반 객체 뒤, 맨 끝에 위치 시킵니다.

동반 객체를 왜 마지막에 위치 시키나요?
보통 Java에서 public static final String Foo ... 같은 식으로 정의하는 정적 상수를 Kotlin에서 동반 객체에 정의하게 되고, 이런 경우Java에서의 습관에 따라 상단에 동반 객체를 위치시키게 됩니다. Kotlin 코딩 규칙에서 동반 객체의 위치가 아래인 이유는 동반 객체가 중첩 클래스 같이 별도의 로직을 갖는 하나의 객체 단위이기 때문입니다. 개인적인 생각으로는 동반 객체를 하단에 위치 시키는 규칙은 적절히 필요에 맞게 따르면 될 거 같습니다. 즉, 동반 객체가 단순히 정적 상수들만 담는 경우에는 Java처럼 클래스 상단데, 그렇지 않고 로직을 많이 담는 경우에는 하단에 위치 시키면 될 거 같습니다.

 

인터페이스 구현 레이아웃

인터페이스를 구현할 때, 멤버들의 순서는 인터페이스의 순서를 그대로 유지합니다(필요한 경우 사이, 사이에 구현에 필요한 private 메소드를 추가할 수 있습니다).

 

오버로드 레이아웃

클래스 내에서 오버로드 메소드들은 항상 같이 이웃하게 둡니다.

 

명명 규칙(Naming rules)

Kotlin에서 패키지와 클래스 명을 짓는 규칙은 매우 단순합니다.

  • 패키지 이름은 항상 소문자를 사용하고, 밑줄(_)을 사용하지 않습니다(com.example.project). 여러 개의 단어로 구성된 이름은 일반적으로 권장되지 않지만, 반드시 필요한 경우에는 해당 단어들을 모두 붙여 쓰거나(com.example.myproject) 카멜 케이스를 사용합니다(com.example.myProject)
  • 클래스나 객체 이름은 대문자로 시작하는 카멜 케이스를 사용합니다(파스칼 케이스).
open class DeclarationProcessor { /*...*/ }

object EmptyDeclarationProcessor : DeclarationProcessor() { /*...*/ }

 

함수 이름

함수, 프로퍼티, 지역 변수의 이름은 소문자로 시작하고 카멜케이스를 사용합니다. 밑줄(_)은 사용하지 않습니다.

fun processDeclarations() { /*...*/ }
var declarationCount = 1

 

예외: 클래스의 인스턴스를 생성할 때 사용하는 팩토리 함수는 추상 반환 타입과 같은 이름을 가질 수 있습니다.

interface Foo { /*...*/ }

class FooImpl : Foo { /*...*/ }

fun Foo(): Foo { return FooImpl() }

 

테스트 메소드 이름

테스트의 경우 메소드 이름을 백틱(`)으로 묶어서 빈칸을 사용할 수 있습니다. 이러한 이름은 안드로이드의 경우 API 30 이후부터만 지원합니다. 밑줄(_) 역시 테스트 메소드의 이름에는 허용됩니다.

class MyTestCase {
     @Test fun `ensure everything works`() { /*...*/ }

     @Test fun ensureEverythingWorks_onAndroid() { /*...*/ }
}

 

프로퍼티 이름

상수(const로 지정된 프로퍼티, 최상위 수준이나 object의 깊은 불변 데이터를  갖는 사용자 맞춤 getter가 없는  val 프로퍼티)의 이름은 밑줄로 구분된 대문자 형태로 이름을 짓습니다(이를 스크리밍 스네이크 케이스라고 부릅니다).

const val MAX_COUNT = 8
val USER_NAME_FIELD = "UserName"

 

최상위 수준이나 행위 또는 가변 데이터를 갖는 객체를 가지고 있는 object의 프로퍼티는 카멜케이스를 사용합니다.

val mutableCollection: MutableSet<String> = HashSet()

 

싱글톤 object의 참조를 가지고 있는 프로퍼티 이름은 object 선언과 같은 유형으로 이름을 지을 수 있습니다.

val PersonComparator: Comparator<Person> = /*...*/

 

enum 상수는 상황에 따라 밑줄로 구분된 대문자(스크리밍 스네이크 케이스)나 첫글자 대문자 카멜 케이스(파스칼 케이스) 모두 괜찮습니다.

enum class Color {
    RED,
    BLUE,
    YELLO_GREEN,
}

enum class Background {
    Blue,
    YelloGreen,
}

 

뒷받침(backing) 프로퍼티의 이름

클래스가 개념적으로 같은 두 개의 프로퍼티를 가지고 있는데 하나는 외부에 공개하는 것이고, 하나는 내부에서 사용하는 상세 구현에 포함되는 것이라면, 내부용 private 프로퍼티에는 밑줄을 접두어로 붙입니다.

class C {
    private val _elementList = mutableListOf<Element>()

    val elementList: List<Element>
         get() = _elementList
}

 

좋은 이름 선택

클래스 이름은 해당 클래스가 무엇인지 설명하는 명사나 명사구를 사용합니다.

  • List, PersonReader

메소드 이름은 무엇을 하는지 나타내는 동사나 동사구를 사용합니다. 예를 들면, close, readPersons 등입니다. 또한, 메소드 이름은 메소드가 객체를 변경하는지 아니면 새로운 객체를 반환하는지 나타낼 수 있어야 합니다. 예를 들어, sort는 컬렉션 자체를 변경하여 정렬하지만, sorted는 정렬된 컬렉션의 복사본을 반환합니다.

 

이름은 대상의 목적이 분명히 드러나도록 해야합니다. 그래서, Manager나 Wrapper 같이 의미없는 단어는 가능하면 사용하지 않는 것이 좋습니다.

 

선언하는 이름에 두문자어를 사용할 때는, 두문자어가 2개의 문자인 경우 대문자로 쓰고(예: IOStream), 그보다 길면 첫 문제가 대문자로 표기합니다(예: XmlFormatter, HttpInputStream).

 

서식(Formatting)

들여쓰기

들여쓰기를 위해 빈칸(space)을 사용합니다. 탭을 사용하지 않습니다.

중괄호의 경우 여는 괄호는 구문이 시작되는 줄 끝에 두고,  닫는 괄호는 별도의 줄에 여는 구문의 시작과 수평으로 정렬된 위치에 둡니다. 

if (elements != null) {
    for (element in elements) {
        // ...
    }
}
Kotlin에서 세미콜론은 선택사항이기 때문에 줄바꿈이 중요합니다. 자바 스타일의 괄호를 전체로 설계됐기 때문에 다른 스타일로 사용하게 되는 경우 예상 밖의 원치 않는 결과가 나올 수도 있습니다.

 

수평 공백

  • 두 개의 피연산자를 갖는 연산자 앞 뒤로는 빈칸을 둡니다(a + b). 예외적으로, 범위 연산자에는 빈칸을 사용하지 않습니다(0..i).
  • 단항 연산자에는 빈칸을 사용하지 않습니다( a++ ).
  • 흐름 제어 키워드(if, when, for, while)와 괄호 사이에는 빈칸을 둡니다.
  • 주 생성자와 메소드의 선언과 메소드의 호출을 여는 괄호의 앞에는 빈칸을 두지 않습니다.
class A(val x: Int)

fun foo(x: Int) { ... }

fun bar() {
    foo(1)
}
  • (, [ 뒤나 ), ] 앞에 절대로 빈칸을 추가하지 않습니다.
  • . 이나 ?. 앞뒤로 공백을 사용하지 않습니다. 예) foo.bar().filter { it > 2 }.joinToString(), foo?.bar()
  • // 뒤에는 빈칸을 추가합니다.  예) // 주석입니다.
  • 타입 매개변수를 나타내는 데 사용하는 꺾쇠괄호 앞뒤로는 빈칸을 사용하지 않습니다. 예) class Map<K, V> { ... }
  • :: 앞뒤로 빈칸을 두지 않습니다. 예) Foo::class, String::length
  • 널가능 타입을 나타내는 ? 앞에는 빈칸을 사용하지 않습니다. 예) String?

일반적인 규칙으로, 어떠한 종류의 수평 정렬도 사용하지 않습니다. 식별자의 이름을 다른 길이로 변경하더라도 다른 선언이나 사용부분의 서식에 영향을 줘서는 안 됩니다.

 

콜론

다음과 같은 경우에는 : 앞에 빈칸을 둡니다.

  • 타입과 수퍼타입을 구분할 때
  • 수퍼 클래스의 생성자나 같은 클래스의 다른 생성자에게 위임할 때
  • object 키워드 다음에 올 때

선언과 그 타입을 지정할 때 사용하는 : 앞에는 빈칸을 두지 않습니다.

 :  뒤에는 항상 빈칸을 사용하세요.

abstract class Foo<out T : Any> : IFoo {
    abstract fun foo(a: Int): T
}

class FooImpl : Foo() {
    constructor(x: String) : this(x) { /*...*/ }

    val x = object : IFoo { /*...*/ }
}

 

클래스 헤더

적은 수의 매개변수가 있는 주 생성자를 갖는 클래스는 다음과 같이 한 줄로 작성할 수 있습니다.

class Person(id: Int, name: String)

 

긴 헤더를 갖는 클래스는 주 생성자의 각각의 매개변수를 각각의 줄에 들여쓰기하는 식으로 작성합니다. 그리고, 닫는 괄호는 새로운 줄에 위치해야 합니다. 상속을 사용하는 경우 수퍼 클래스의 생성자를 호출하거나 구현하는 인터페이스를 열거는 닫는 괄호와 같은 줄에 위치 시킵니다.

class Person(
    id: Int,
    name: String,
    surname: String
) : Human(id, name) { /*...*/ }

 

다중 인터페이스를 구현하는 경우에는 수퍼 클래스의 생성자 호출은 반드시 처음으로 위치해야 하고, 그 이후에 인터페이스들은 각각의 줄에 위치해야 합니다.

class Person(
    id: Int,
    name: String,
    surname: String
) : Human(id, name),
    KotlinMaker { /*...*/ }

 

긴 수퍼 타입 목록을 갖는 클래스는 콜론 다음에서 줄 바꿈하여 한 줄에 하나씩 수평적으로 정렬하여 열거합니다.

class MyFavouriteVeryLongClassHolder :
    MyLongHolder<MyFavouriteVeryLongClass>(),
    SomeOtherInterface,
    AndAnotherOne {

    fun foo() { /*...*/ }
}

 

클래스 헤더가 긴 경우에 헤더와 몸체를 분리하기 위해 위의 예처럼 헤더 다음에 빈 줄을 하나 추가하거나, 몸체의 여는 중괄호를 별도의 새로운 줄에 둘 수 있습니다.

class MyFavouriteVeryLongClassHolder :
    MyLongHolder<MyFavouriteVeryLongClass>(),
    SomeOtherInterface,
    AndAnotherOne
{
    fun foo() { /*...*/ }
}

 

생성자 매개변수를 위해서 일반적인 들여쓰기(4개의 빈칸)를 사용합니다. 이렇게 하면 주 생성자에서 선언되는 프로퍼티와 클래스와 몸체에서 선언되는 프로퍼티가 같은 들여쓰기를 갖게됩니다.

 

수정자(Modifier) 순서

선언에 여러 수정자가 쓰인다면 다음의 순서를 따릅니다.

public / protected / private / internal
expect / actual
final / open / abstract / sealed / const
external
override
lateinit
tailrec
vararg
suspend
inner
enum / annotation / fun // as a modifier in `fun interface`
companion
inline / value
infix
operator
data
modifier에 대한 번역
modifier는 제한자, 한정자, 제어자, 수정자, 변경자 등으로 번역되어 쓰이고 있는 거 같습니다. 몇몇 사전을 보면 modifier는 다음과 같이 정의하고 있습니다.
위키피디아 "a word that modifies the meaning of another word or limits its meaning"
캠브리지 사전 "a word or phrase that is used with another word or phrase to limit or add to its meaning"
옥스포드 사전 "a word or group of words that describes a noun phrase or limits its meaning in some way"
개인적으로는 습관적으로 한정자를 많이 써 왔지만, 이 번에 글을 쓰면서 다르게 써야하지 않을까 하는 생각이 좀 들었습니다. access modifier 같은 경우에는 한정자라는 말이 바로 와 닿지만, tailrec, lateinit 등등은 한정자라는 말보다는 변경한다는 뜻을 지닌 수정자 등의 단어가 더 와 닿는 거 같습니다. 수정자라고 하면 access modefier에서와 그 외의 경우에도 모두 의미가 통할 수 있기 때문에 이 연재에서는 수정자라고 하겠습니다.

annotation
annotation은 우리말로 주석입니다만, 프로그래밍 언어에서는 주석(comment)과 구분이 안 되기 때문에 보통 외래어처럼 어노테이션(또는 애너테이션)이라고 적거나 말합니다. 발음 기호 상으로는 애너테이션이 더 비슷하기도 하나 이 연재에서는 어노테이션으로 표기하도록 하겠습니다.

 

모든 어노테이션은 수정자 앞에 둡니다.

@Named("Foo")
private val foo: Foo

 

라이브러리 작업을 하는 경우가 아니라면 불필요한 수정자는 생략합니다(예: public).

 

어노테이션

어노테이션은 적용하는 대상 선언 전에 별도의 줄로 작성하고, 대상 선언과 같은 들여쓰기를 갖도록 합니다.

@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

 

인수(argument)가 없는 어노테이션들은 갖은 줄에 위치시킬 수도 있습니다.

@JsonExclude @JvmField
var x: String

 

인수가 없는 하나의 어노테이션은 선언과 같은 줄에 작성할 수 있습니다.

@Test fun foo() { /*...*/ }

 

파일 어노테이션

파일 어노테이션은 파일 주석 다음, 패키지 명 전에 위치 시킵니다.  그리고, 어노테이션인 패키지에 대한 것이 아니라 파일에 대한 것을 강조하기 위해 package와 파일 어노테이션 사이에는 빈 줄을 추가합니다.

/** License, copyright and whatever */
@file:JvmName("FooBar")

package foo.bar

 

함수

함수의 시그니처가 한 줄에 적합하지 않을 때는 다음의 형식을 따릅니다.

fun longMethodName(
    argument: ArgumentType = defaultValue,
    argument2: AnotherArgumentType,
): ReturnType {
    // body
}

 

함수 매개변수는 일반적인 들여쓰기(4개의 빈칸)를 합니다. 이는 생성자 매개변수와 일관성을 같게 합니다.

 

함수의 몸체가 한 줄인 경우에는 표현식 몸체를 선호합니다.

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

fun foo() = 1        // good

 

표현식 몸체(Expression Bodies)

함수의 몸체가 표현식일때 표현식의 첫 줄의 함수 선언과 같은 줄에 작성하기에 길이가 긴 경우에는 = 다음에 줄바꿈을 하여 4칸 들여쓰기 한 후에 표현식을 작성합니다.

fun f(x: String, y: String, z: String) =
    veryLongFunctionCallWithManyWords(andLongParametersToo(), x, y, z)

 

프로퍼티

아주 단순한 읽기 전용 프로퍼티는 한 줄로 작성하는 것을 고려합니다.

val isEmpty: Boolean get() = size == 0

 

보다 복잡한 프로퍼티는 항상 get과 set 키워드를 별도의 줄에 작성합니다.

val foo: String
    get() { /*...*/ }

 

초기화가 있는초기화가 있는 프로퍼티에서 초기화 부분이 긴 경우에는 = 다음에 줄 바꿈을 하고 네 칸 들여쓰기를 합니다. 프로퍼티에서 초기화 부분이 긴 경우에는 = 다음에 줄 바꿈을 하고 네 칸 들여쓰기를 합니다.

private val defaultCharset: Charset? =
    EncodingRegistry.getInstance().getDefaultCharsetForPropertiesFiles(file)

 

흐름 제어문

if나 when 문의 조건이 여러 줄인 경우 해당 문의 몸체를 위해 항상 중괄호를 사용합니다. 조건의 각 줄은 연관된 흐름 제어문의 시작에 상대적으로 네 칸을 들여쓰기 합니다. 조건을 닫는 괄호와 몸체를 위한 여는 중괄호는 별도의 줄에 같이 위치시킵니다.

if (!component.isSyncing &&
    !hasAnyKotlinRuntimeInScope(module)
) {
    return createKotlinNotConfiguredPanel(module)
}

 

이렇게 하는 것은 조건과 몸체를 일치하게 정렬하는데 도움이 됩니다.

 

else, catch, finally, do-while의 while 키워드는 앞선 중괄호와 같은 줄에 위치 시킵니다.

if (condition) {
    // body
} else {
    // else part
}

try {
    // body
} finally {
    // cleanup
}

 

when 문에서 분기(branch)가 한 줄 이상인 경우 이어지는 케이스 블록과 한 줄을 띄우는 것을 고려합니다.

private fun parsePropertyValue(propName: String, token: Token) {
    when (token) {
        is Token.ValueToken ->
            callback.visitValue(propName, token.value)

        Token.LBRACE -> { // ...
        }
    }
}

 

짧은 분기는 중괄호 없이 조건과 같은 줄에 작성합니다.

when (foo) {
    true -> bar() // good
    false -> { baz() } // bad
}

 

메소드 호출

인수(argument) 목록이 긴 경우 여는 괄호에서 줄 바꿈을 하고 4칸 들여쓰기 인수를 작성합니다. 밀접한 관련이 있는 인수들은 같은 줄에 둡니다.

drawSquare(
    x = 10, y = 10,
    width = 100, height = 100,
    fill = true
)

 

인수의 이름과 값을 구분하는 = 앞뒤로는 빈칸을 추가합니다.

 

연속 호출 줄바꿈 (Wrap chained calls)

연속 호출을 줄바꿈 할 때는 . 나 ?. 연산자를 새로운 줄에 들여쓰기하여 작성합니다.

val anchor = owner
    ?.firstChild!!
    .siblings(forward = true)
    .dropWhile { it is PsiComment || it is PsiWhiteSpace }

연속 호출의 첫번째 앞에는 줄바꿈이 있어야 하지만, 줄바꿈을 안 했을 때가 더 보기 좋다면 생략해도 괜찮습니다.

 

람다

람다 표현식에서 중괄호 앞뒤에 빈킨을 둡니다. 또한 몸체와 매개변수를 분리하는 화살표 앞뒤로도 빈칸을 추가합니다. 단일 람다를 사용하여 호출하는 경우 가능하면 괄호 바깥으로 위치 시킵니다.

list.filter { it > 10 }

 

람다에 라벨을 지정하는 경우, 라벨과 여는 중괄호 사이에 빈칸을 두지 않습니다.

fun foo() {
    ints.forEach lit@{
        // ...
    }
}

 

여러 줄을 갖는 람다에서 매개변수 이름들을 선언할 때는 첫 줄에 이름들과 화살표(->)를 작성하고 줄바꿈을 합니다.

appendCommaSeparated(properties) { prop ->
    val propertyValue = prop.get(obj)  // ...
}

 

매개변수 목록이 길어서 한 줄에 적합하지 않은 경우에는 화살표를 별도의 줄에 둡니다.

foo {
   context: Context,
   environment: Env
   ->
   context.configureEnv(environment)
}

 

후행 콤마(Trailing commas)

후행 콤마는 구성 요소의 연속에서 마지막 항목 뒤에 콤마 심볼입니다.

class Person(
    val firstName: String,
    val lastName: String,
    val age: Int, // trailing comma
)
'후행 쉼표'라고도 하고, 현업에서는 트레일링 콤마라고 더 많이 얘기할 거 같습니다. 콤마 관련해서는 통상 코딩 얘기할 때는 쉼표 보다는 콤마라는 표현을 더 많이 쓰고, 콤마도 외국어가 아닌 외래어이기 때문에 본 연재에서는 콤마라는 단어를 사용하기로 했습니다.

 

후행 콤마를 사용하면 몇가지 이점이 있습니다.

  • 코드 형상 관리에서 차이점을 보여줄 때 보다 깔끔합니다. 그리고, 변경된 부분에서 초점을 맞출 수 있게 해 줍니다.
  • 요소를 추가하거나 순서를 바꾸기 쉽게 해 줍니다. 즉, 해당 작업으로 인해 콤마를 추가하거나 지울 필요가 없습니다.
  • 객체 초기화 같은 코드 생성을 단순하게 해 줍니다. 마지막 요소는 또한 콤마를 가질 수 있습니다.

후행 콤마 없이도 아무런 문제가 없다면, 후행 콤마는 전적으로 선택 사항입니다. Kotlin 스타일 가이드에서는 선언부에서는 후행 콤마 사용을 권장하고, 호출부에서는 사용을 개발자 재량에 맞기고 있습니다.

 

후행 콤마의 이점 중 첫번째 이유만으로도 사용하실 것을 권해 드립니다. 후행 콤마를 사용하지 않은 상태에서 새로운 요소를 추가하면, 변경된 코드는 이전 항목에 콤마가 붙고, 그 다음에 새로운 항목이 추가될 것입니다. 이런 경우 이전 항목에는 단지 콤마만 추가된 것일 뿐인데도Git 같은 형상 관리 툴에서 변경 내력을 이전 항목 부분과 새로 추가된 항목이라고 보여주게 됩니다. 하지만, 후행 콤마를 이전 작업때 붙여 놓으면 이전 항목은 신경 쓰지 않고 새로운 항목만이 추가되므로, 변경사항에서도 변경된 항목만이 나타나게 됩니다.

후행 콤마를 지원하는 언어를 사용하는 경우 붙이는 습관을 들이면 좋습니다.

 

이 부분에 공식 문서에서는  인텔리제이 IDEA에서 후행 콤마 설정 관련해서 이야기 하고 있는데 생략하도록 하겠습니다. 관심 있는 분들은 여기를 참고하시기 바랍니다. 

 

문서화 주석

긴 문서화 주석은 /**로 시작해서 줄 바꿈을 하고 각 줄에는 별표를 붙입니다.

/**
 * This is a documentation comment
 * on multiple lines.
 */

 

짧은 주석은 한 줄로 표기할 수 있습니다.

/** This is a short documentation comment. */

 

일반적으로, @param과 @return 태그 사용을 자제합니다. 대신에, 매개변수와 반환값에 대한 설명을 문서화 주석에 직접 포함시키고, 주석에서 언급되는 매개변수에는 링크를 추가합니다. 본문의 흐름에 맞지 않는 긴 설명이 필요한 경우에만 @param과 @return을 사용합니다.

// 이런 방식을 지양하고,

/**
 * Returns the absolute value of the given number.
 * @param number The number to return the absolute value for.
 * @return The absolute value.
 */
fun abs(number: Int): Int { /*...*/ }

// 대신에 이렇게 합니다.

/**
 * Returns the absolute value of the given [number].
 */
fun abs(number: Int): Int { /*...*/ }

 

불필요한 구성 요소 피하기 (Avoid redundant constructs)

일반적으로, Kotlin에서 어떤 구문적 요소가 선택사항이고 IDE에서 불필요한 것으로 표기 된다면 생략하는 것이 좋습니다. 명확성을 위해 코드 내에 불필요한 구문적 요소를 남겨 놓지 마시기 바랍니다.

 

Unit return type

함수가 Unit을 반환하는 경우, 반환 타입은 생략해야 합니다.

fun foo() { // 여기에서 ": Unit"이 생략됩니다.

}

 

세미콜론

가능하면 세미콜론은 생략합니다.

 

문자열 템플릿

문자열 템플릿에 단순히 변수를 추가하는 경우에는 중괄호를 사용하지 않습니다. 보다 긴 표현식에 대해서만 중괄호를 사용합니다.

println("$name has ${children.size} children")

 

언어 기능의 관용적 사용

불변성

가변성보다 불변성 사용을 선호합니다. 지역 변수나 프로퍼티가 초기화 된 후 변경되지 않는다면, 항상 var 보다는 val 사용하여 선언합니다.

 

변경되지 않을 컬렉션을 선언할 때는 항상 불변 컬렉션 인터페이스(Collection, List, Set, Map)를 사용합니다. 컬렉션 인스턴스를 생성하는 팩토리 함수를 사용할 때는 가능하다면 항상 불변 컬렉션을 반환하도록 합니다.

// 나쁨: 값이 변경되지 않는 경우에 가변 컬렉션 사용
fun validateValue(actualValue: String, allowedValues: HashSet<String>) { ... }

// 좋음: 대신에 불변 컬렉션 사용
fun validateValue(actualValue: String, allowedValues: Set<String>) { ... }

// 나쁨: arrayListOf()는 가변 컬렉션 타입인 ArrayList<T>를 반환
val allowedValues = arrayListOf("a", "b", "c")

// 좋음: listOf()는 불변인 List<T>를 반환
val allowedValues = listOf("a", "b", "c")

 

매개변수 기본 값

함수를 오버로딩 하기 보다는 매개변수에 기본 값을 지정하여 함수를 선언하는 것을 우선합니다.

// Bad
fun foo() = foo("a")
fun foo(a: String) { /*...*/ }

// Good
fun foo(a: String = "a") { /*...*/ }

 

타입 별칭(Type aliases)

함수형 타입이나 타입 매개변수를 갖는 타입을 여러번 사용하게 되는 경우에는 타입 별칭을 사용하는게 좋습니다.

typealias MouseClickHandler = (Any, MouseEvent) -> Unit
typealias PersonIndex = Map<String, Person>

 

이름이 같아 충돌하는 것을 피하기 위해 private이나 내부 타입 별칭을 사용해야 하는 경우라면, (여기에서 언급하고 있는) import ... as ... 를 사용하는게 좋습니다.

 

람다 매개변수

람다가 짧고, 중첩되지 않는 경우에는 명시적으로 매개변수를 선언하는 대신에 it 사용이 권장됩니다. 매개변수가 있는 중첨된 람다에서는 항상 명시적으로 매개변수를 선언합니다. 관련해서 좀 더 설명이 필요한 경우 여기를 참고하세요.

 

람다에서의 반환

람다에서 여러번의 라벨이 붙은 반환은 피해야 합니다. 그런 경우에는 단일 출구(exit)를 갖는 구조로 재구성 하는 것을 고려해야 합니다. 만약, 그렇게 할 수 없거나 재구성이 충분히 명확하지 않을 때는 람다를 익명 함수로 변환하는 것을 고려해 볼 수 있습니다.

 

람다의 마지막 문장을 위해 라벨이 붙은 return을 사용하지 않습니다. 마지막 내용이 암묵적으로 반환 되기 때문입니다. 그래서, 아래의 두 코드는 동일한 코드입니다.

ints.filter {
    val shouldFilter = it > 0
    shouldFilter
}

ints.filter {
    val shouldFilter = it > 0
    return@filter shouldFilter
}

 

이름 지정 인수(Named arguments)

메소드가 여러개의 같은 원시 타입 인수를 받거나 Boolean 타입 매개변수인 경우, 또는문맥상으로 모든 매개변수의 의미가 명확하지 않은 경우에는 이름 지정 인수 구문을 사용합니다.

drawSquare(x = 10, y = 10, width = 100, height = 100, fill = true)

 

조건문

try, if, when의 표현식 형태 사용을 선호합니다.

return if (x) foo() else bar()
return when(x) {
    0 -> "zero"
    else -> "nonzero"
}

 

위의 형태가 아래의 형태보다 좋습니다.

if (x)
    return foo()
else
    return bar()
when(x) {
    0 -> return "zero"
    else -> return "nonzero"
}

 

if vs when

조건이 2가지인 경우에는 when 보다는 if를 사용합니다. 예를 들면, 이 형식 보다는

when (x) {
    null -> // ...
    else -> // ...
}

 

이렇게 사용합니다.

if (x == null) ... else ...

 

조건이 셋 이상인 경우에는 when을 사용합니다.

 

조건에서 널 가능한 Boolean 값

조건문에서 널 가능한 Boolean을 사용해야 하는 경우에는 if (value == true) 나 if (value == false) 를 사용합니다.

 

반복(loop)

반복(loop) 보다는 고차함수(filter, map 등등)를 사용합니다. 예외적으로 forEach 경우에는 forEach의 수신자가 널 가능하거나, forEach가 긴 호출 연결에 한 부분으로 사용되는 경우가 아니라면 for를 우선해서 사용합니다.

 

복수의 고차 함수를 사용한 복합적인 표현식과 반복문 사용 중 어떤 것을 사용할지 결정할 때는 각각의 실행 경우에 대한 연산 비용을 이해해야 하고 성능에 대한 고려를 염두에 두어야 합니다.

 

범위 반복

개방형 범위에 대해서는 ..< 를 사용합니다.

for (i in 0..n - 1) { /*...*/ }  // bad
for (i in 0..<n) { /*...*/ }  // good

 

문자열

문자열 연결보다 문자열 템플릿을 사용합니다.

 

일반 문자열에 \n을 추가하는 것보다, 여러줄 문자열을 사용합니다.

 

여러줄 문자열에서 들여쓰기를 유지하기 위해서, 결과 문자열이 내부 들여쓰기가 요구되지 않은 경우 trimIndent를 사용하고, 내부적으로 들여쓰기가 요구되는 경우 trimMargin을 사용합니다.

fun main() {
    println("""
    Not
    trimmed
    text
    """
           )

    println("""
    Trimmed
    text
    """.trimIndent()
           )

    println()

    val a = """Trimmed to margin text:
          |if (a > 1) {
          |    return a
          |}""".trimMargin()

    println(a)
}
/* 실행 결과
    Not
    trimmed
    text
    
Trimmed
text

Trimmed to margin text:
if (a > 1) {
    return a
}
*/

 

여러줄 문자열에 대한 Java와 Kolin의 차이점을 여기에서 알아두면 좋습니다.

 

함수 vs 프로퍼티

경우에 따라 인수가 없는 함수는 읽기 전용 프로퍼티로 상호 교환될 수 있습니다. 의미적으로는 비슷하지만 어떤 것을 더 선호해야하는지 몇가지 스타일적 규칙이 있습니다.

 

기본 알고리즘이 다음과 같을 때는 함수보다 프로퍼티 사용을 우선합니다.

  • 예외를 던지지 않을 경우
  • 연산 비용이 싸거나 첫 실행시 캐싱되는 경우
  • 객체의 상태가 변경되지 않는 한 반복적으로 호출해도 같은 결과를 반환하는 경우

 

확장 함수

확장 함수를 자유롭게 사용합니다. 매번 특정 객체에 대해서 주로 사용되는 함수가 있는 경우 해당 객체를 수신자로 받는 확장 함수로 만들 수 있는지 고려해 봅니다. API가 오염되는 것을 최소하하기 위해서, 가능한 한 확장 함수의 가시성을 제한해야 합니다. 필요에 따라, 지역 확장 함수나 멤버 확장 함수 또는 private 가시성의 최상위 수준 확장 함수를 사용합니다.

 

중위 함수(Infix functions)

비슷한 역할을 수행하는 두 개의 객체에 대해서 동작할 때만 중위 함수를 선언합니다.

좋은 예: and, to, zip

나쁜 예: add

 

메소드가 수신 객체를 변경하는 경우에는 중위 연산으로 선언하지 않습니다.

 

add(+)가 나쁜 이유는 다음과 같은 경우입니다.

val fruits = mutableListOf("orange", "melon")
fruits += "apple" // 이 중위 연산을 통해 수신 객체(fruits)를 변경하게 됩니다.

 

팩토리 함수

클래스를 위한 팩토리 함수를 선언하는 경우 클래스와 같은 이름으로 짓는 것은 피해야합니다. 고유한 이름을 사용하여 팩토리 함수의 동작이 특별한 이유를 명확히 하는 것이 좋습니다. 실제로 어떠한 특별한 의미도 없는 경우(팩토리 함수가 특별한 작업을 안 하는 경우)에만 클래스와 같은 이름을 사용할 수 있습니다.

class Point(val x: Double, val y: Double) {
    companion object {
        fun fromPolar(angle: Double, radius: Double) = Point(...)
    }
}

 

각각 다른 수퍼클래스 생성자를 호출하지도 않고 기본값 인수를 사용하여 단일 생성자로 축소할 수도 없는 여러 개의 오버로드된 생성자가 있는 경우에는, 오버로드된 생성자를 팩토리 함수로 대체하는 것이 좋습니다.

 

플랫폼 타입

플랫폼 타입의 표현식을 반환하는 public 함수/메소드는 해당하는 Kotlin 타입으로 명시적으로 선언돼야 합니다.

// getProperty가 자바 플랫폼의 타입을 반환한다면,
// 타입을 추론되게 하지말고, : String 식으로 그에 대응하는 Kotlin 타입을 명시적으로 표시합니다.
fun apiCall(): String = MyJavaApi.getProperty("name")

 

패키지 수준이나 클래스 수준의 프로퍼티가 플랫폼 타입의 표현식으로 초기화 되는 경우 반드시 Kotlin 타입을 명시적으로 표기합니다.

class Person {
    val name: String = MyJavaApi.getProperty("name")
}

 

플랫폼 타입으로 초기화 되는 지역 변수에 대해서는 타입 선언을 할 수도, 안 할 수도 있습니다.

fun main() {
    val name = MyJavaApi.getProperty("name")
    println(name)
}

 

범위(scope) 함수 apply/with/run/also/let

Kotlin은 주어진 객체의 문맥 범위에서 코드 블록을 실행하는 apply, with, run, also, let 같은 함수를 제공합니다. 경우에 따라 범위 함수를 사용해야되는지에 대한 가이드는 여기를 참고합니다.

추후 해당 문서를 한 번 다뤄볼 예정입니다. 인터넷 상에 범위 함수에 대해 참고할 수 있는 많은 글들이 있겠지만, 꼭 공식 문서를 한 번 읽어 보실 것을 권해 드립니다. 시간을 들여서 읽어볼 만큼 충분한 가치가 있습니다.

 

라이브러리를 위한 코딩 규칙

라이브러리를 작성할 때는 API의 안정성을 보장하기 위해 다음과 같은 추가적인 규칙이 권고됩니다.

  • 항상 명시적으로 멤버의 가시성(visibility)을 표기합니다.
    • 특정 선언이 의도치 않게  public API로 노출되는 것을 방지 
  • 항상 함수의 반환 타입과 프로퍼티의 타입을 명시적으로 지정합니다
    • 구현이 변경됐울 때 의도치 않게 반환 타입이 변경되는 것을 방지
  • 새로운 문서화가 필요 없는 것들을 제외하고는, 모든 public 멤버에 대해서 KDoc 주석을 제공합니다.
    • 라이브러리 문서 생성을 위해서 필요

추가적으로, 라이브러리 제작자 가이드라인을 통해 API를 작성할 때 고려해야할 아이디어나 모범 사례를 익히면 좋습니다.