System.out.print를 매 프로젝트마다 각각 구현해야 한다면 피곤한 일이 될 것이다.
누군가가 한 번 만들어 놓고, 필요할 때 이를 활용할 수 있게 만든 것이 프로그래밍 언어의 핵심 특징 중 하나인 재사용성이다.
재사용성은 큰 힘이 있는 만큼 잘 생각하고 사용해야 한다.
아이템 19 - knowldeg를 반복하여 사용하지 말라
여러 요소에 비슷한 부분이 있는 경우, 변경이 필요할 때 실수가 발생할 수 있습니다. 이런 부분은 추출하는 것이 좋습니다.
다만, Don't Repeat Yourself라는 문장을 엄격하게 지키려고 해서, 비슷해보이는 코드를 모두 추출하려고 해서는 안됩니다.
극단적인 것은 언제나 좋지 않으며, 항상 균형이 중요합니다.
실용주의 프로그래머 책에서는 Don't Repeat Yourself라는 DRY 규칙을 이야기합니다. 이는 WET 안티패턴이라고도 알려져 있습니다. 이 원칙이 잘 맞는 이야기는 맞지만, 오용/남용되는 경우도 존재합니다. 이 규칙을 적용할 때에는 언제 사용해야 하는지 제대로 이해하고 사용해야 합니다.
Don't Repeat Yourself (DRY 규칙)
모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.
여기서 말하는 지식이란, 의도적인 정보(knowledge)를 의미합니다.
We Enjoy Typing, Waste Everyone's Time or Write Everything Twice(WET 안티패턴)
"많은 사람이 같은 코드를 두 번씩 작성하는 의미 없는 행동을 하는데, 이는 아마도 타이핑하기 좋아하기 때문일 것이다"라는 의미의 안티패턴
Knowledge
프로그래밍에서 knowledge는 의도적인 정보를 의미합니다. 프로젝트를 진행할 때 정의한 모든 것이 knowledge이며, knowledge의 종류는 다양합니다. 예를 들면, 알고리즘의 작동 방식, 우리가 원하는 결과 등이 모두 의도적인 정보라고 볼 수 있습니다.
프로그램에서 중요한 knowledge를 두 가지 뽑는다면 아래와 같습니다.
- 로직(logic): 프로그램이 어떻게 동작하는지와 프로그램이 어떻게 보여지는지
- 공통 알고리즘(common algorithm): 원하는 동작을 하기 위한 알고리즘
위 knowledge의 가장 큰 차이점은 시간에 따른 변화입니다. 비즈니스 로직은 시간이 지나면서 계속 변화하지만, 공통 알고리즘은 한 번 정의되면 크게 변화하지 않습니다. 물론 공통 알고리즘을 리팩토링할 순 있지만, 동작 자체에는 크게 변화가 일어나지 않습니다.
모든 것은 변화하고, 이에 대비해야 한다
프로그래밍 속성 중 유일하게 유지되는 것은 변화한다는 점입니다. 예를 들어, 10년 전만해도 UI를 그릴 때 react를 이렇게 많이 활용할 것이라고 여기지 못했고, kotlin의 stable 버전 배포는 2016년이었습니다.
프로그래밍 뿐 아니라, 법과 과학 등에 기반에 둔 것들 조차 시간이 지나면서 변화합니다.
과거 아인슈타인이 학생들에게 시험문제를 냈을 때, 한 학생이 작년과 문제가 동일하다고 컴플레인을 했습니다. 아인슈타인은 동일한 문제가 맞다고 인정했지만, 실제로는 문제의 답이 전년도와 완전히 달랐습니다. 무엇도 변화하지 않고 가만히 있는 것은 없습니다.
프로그래밍이 변화하는 이유가 무엇일까?
프로그래밍이 변화하는 이유는 간단합니다.
- 회사가 사용자의 요구 또는 습관을 더 많이 알게 되었다.
- 디자인 표준이 변화했다.
- 플랫폼, 라이브러리, 도구 등이 변화해서 대응해야 한다.
회사 메신저로 자주 사용되는 슬랙은 원래 글리치라는 온라인 게임이었습니다.
게임은 중단되었지만, 해당 게임의 채팅 방식을 좋아해주었던 사용자가 많아 현재의 슬랙이 생겨나게 되었습니다.
위에서 설명한 변화는 언제든 일어날 수 있고, 발빠르게 변화에 대응할 줄 알아야 합니다. 변화가 발생할 때 가장 큰 적은 knowledge가 반복되어 있는 부분입니다. 같은 코드가 여러 부분에 반복되어 있다면, 그저 모두 변경하면 된다고 단순하게 생각할 수 있습니다. 하지만, 변경하는 과정에서 일부가 누락되거나, 잘못된 방향으로 수정이 일어날 수 있습니다. 물론, 귀찮다는 것도 큰 불편함입니다.
knowledge 반복은 프로젝트 확장성(scalable)을 막고, 쉽게 깨지게(fragile) 만듭니다.
언제 코드를 반복해도 될까?
다만, 항상 반복되는 코드를 방지해야 하는 것은 아닙니다. 얼핏보면 같은 knowledge로 보여도 실질적으로 다른 knowledge를 나타낼 수도 있습니다.
- 독립적인 두 개의 API가 있고, 기획서가 각각 따로 있을 경우 둘 중 하나에만 특정 요건이 추가될 수 있습니다.
- 이때, 하나의 knowledge로 취급해 합친 서비스를 만들었다면, 변경이 필요할 때 두 API 모두에 영향을 끼칠 수 있고, 가독성이 더 떨어질 수 있습니다.
두 코드가 같은 knowledge를 나타내는지를 판단할 때는 아래와 같은 질문을 던지면 좋습니다.
함께 변경될 가능성이 높은가? 따로 변경될 가능성이 높은가?
비즈니스 큐칙이 다른 곳(source)으로부터 왔다면, 독립적으로 변경될 가능성이 높습니다.
- 코드를 추출하는 이유가 변경을 쉽게 하기 위함이기 때문에, 이 질문이 근본적인 질문이 될 수 있습니다.
단일 책임 원칙(Single Responsibility Principle, SRP)
단일 책임 원칙이란, 클래스를 변경하는 이유는 단 한 가지여야 한다는 의미입니다. 이는 두 액터(actor)가 같은 클래스를 변경하는 일은 없어야 한다는 의미이며, 액터란 변화를 만들어내는 존재로 서로 분야가 다른 개발자라고 볼 수 있습니다.
예를 들어, 아래와 같이 Student 클래스에 두 가지 프로퍼티가 있다고 가정합니다.
qualifiesForScholarship
: 장학금 관련 부서에서 만든 프로퍼티로, 장학금 받을만큼의 포인트가 있는지 나타냄isPassing
: 인증 관련 부서에서 만든 프로퍼티로, 학생이 인증을 통과했는지 나타냄
위 두 프로퍼티는 모두 학생의 이전 학기 성적을 기반으로 계산되어, 두 프로퍼티를 한번에 계산하는 하나의 함수 A를 만들었다고 합시다. 이 경우, 장학금과 관련된 정책이 변경되면 인증 관련 내용도 포함된 A 함수가 영향을 받게 됩니다. 만약, A 함수에 잘못된 수정이 있었다면 인증 관련 부서에도 문제가 발생합니다.
이런 문제를 해결하기 위해서는 애초부터 책임에 따라 다른 클래스로 구분해서 만드는 것이 좋습니다.
- 코틀린에서는, 이럴 때 확장함수를 사용하여 각 확장함수를 관리 부서에 맞는 팀의 파일에서 관리할 수 있습니다.
단일 책임 원칙은 아래와 같은 내용을 깨닫게 해줍니다.
- 서로 다른 곳에서 사용하는 knowledge는 독립적으로 변경될 가능성이 있으므로, 비슷한 처리를 하더라도 다른 knowledge로 취급하는 것이 좋습니다.
- 다른 knowledge라면 분리하는 것이 좋습니다. 같은 곳에 있다면, 재사용하면 안될 내용을 재사용하고자 하는 유혹이 발생할 수 있습니다.
아이템 20 - 일반적인 알고리즘을 반복해서 구현하지 말라
특정 프로젝트에 국한된 비즈니스 로직을 포함한 알고리즘이 아니라면, 이미 존재하는 라이브러리를 활용하거나 범용적으로 사용할 수 있는 유틸리티를 생성하면 좋습니다. 이때, 새로운 유틸리티를 만들어야 한다면, 프로젝트 내부에 직접 확장 함수로 정의하면 좋습니다. 이미 있는 것을 활용하면, 코드가 짧아진다는 장점 외에도 아래와 같은 장점들이 있습니다.
- 코드 작성 속도가 빨라집니다. 알고리즘을 만드는 것보다, 호출하는게 더 빠릅니다.
- 동일한 결과를 얻는 함수를 여러 번 만들면 테스트도 여러 번 진행해야 하고 여러 곳을 유지보수해야 합니다.
- 구현을 따로 읽지 않아도, 함수의 이름 등을 보고 어떤 작업을 하는지 알 수 있습니다.
- 직접 구현할 때 발생할 수 있는 실수를 줄일 수 있습니다.
- 라이브러리를 만든 제작자가 최적화를 하면, 해당 함수를 사용하는 모든 곳에 최적화가 반영됩니다.
이미 존재하는 유용한 대표적 라이브러리는 표준 라이브러리인 stdlib
입니다. stdlib
은 확장 함수를 활용해 만들어진 거대한 유틸리티 라이브러리로, 해당 라이브러리 내에 있는 함수들을 잘 알고 있으면 유용합니다. 확장 함수를 통해 만들어진 유틸리티는 아래와 같은 장점을 갖습니다.
- 톱레벨 함수와 비교해서, 확장 함수는 구체적인 타입이 있는 객체에만 사용을 제한할 수 있어 좋습니다.
- 대상 객체를 아규먼트로 받는것보다 확장 리시버로 지정하는 것이 가독성 측면에서 좋습니다.
- 확장 함수로 구현하면, 자동 완성 기능으로 해당 메소드가 있음을 인지하기 쉬워 유틸리티를 찾기 쉽습니다.
TextUtil.isEmpty("Text")
보다,"Text".isEmpty
를 찾는 것이 더 쉽습니다.TextUtil
클래스가 존재하는지를 아예 모르면,TextUtil.isEmpty()
함수를 찾을 수 없지만, 확장 함수로 구현하면, 대상 객체에 사용할 수 있는 함수를 찾을 때 찾기 쉽습니다.
아이템 21 - 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라
코틀린은 코드 재사용과 관련해서 프로퍼티 위임이라는 기능을 제공합니다. 프로퍼티 위임을 사용하면, 일반적인 프로퍼티의 행위를 추출해서 재사용할 수 있습니다.
프로퍼티 위임은 다른 객체의 메소드를 활용해서 프로퍼티의 접근자(게터와 세터)를 만드는 방식이며, 다양한 프로퍼티의 동작을 추출하여 재사용할 수 있습니다.
프로퍼티 위임을 잘 알고 있으면, 일반적인 패턴을 추출하거나 더 좋은 API를 만들 때 활용할 수 있습니다.
예를 들어, 아래와 같이 프로퍼티의 타입은 다르지만 내부적으로 거의 같은 동작을 할 때에 프로퍼티 위임을 사용하면 유용합니다.
class Test {
var token: String? = null
get() {
println("token returned value $field")
return field
}
set(value) {
println("token set value $value")
field = value
}
var attempts: Int = 0
get() {
println("attempts returned value $field")
return field
}
set(value) {
println("attempts set value $value")
field = value
}
}
위 프로퍼티들에 대해 프로퍼티 위임을 적용하면 아래와 같이 나타낼 수 있습니다.
class Test {
var token: String? by LoggingProperty(null) // null로 초기화한 것으로, 초기화값은 String? 값이면 넣을 수 있음
var attempts: Int by LoggingProperty(0) // 0으로 초기화한 것으로, 초기화값은 Int 값이면 넣을 수 있음
}
class LoggingProperty<T>(private var field: T) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
println("${property.name} returned value $field")
return field
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
println("${property.name} set value $value")
field = value
}
}
위처럼 프로퍼티 위임을 적용하고 decompile해보면, 아래와 같은 결과를 확인할 수 있습니다.
- 코드에서 볼 수 있듯이, getValue와 setValue는 값만 처리하는 것이 아닌 컨텍스트(this)와 프로퍼티 레퍼런스의 경계도 함께 사용하는 형태로 바뀝니다.
- 컨텍스트를 활용하여 getValue와 setValue를 적용하기 때문에, 하나의 프로퍼티 위임 클래스에 여러 getValue와 setValue가 정의되어도 문제 없으며, 이로 인해 프로퍼티 위임은 다양하게 활용될 수 있습니다.
참고로, 프로퍼티 위임을 한 객체를 톱레벨에 정의하게 되면, 아래 예시처럼 this 대신 null로 바뀝니다.
대표적인 위임
코틀린의 표준 라이브러리(stdlib)는 위임과 관련된 몇가지 유용한 팩토리 메소드들을 제공합니다.
lazy 프로퍼티
lazy()는 주어진 람다 블록을 수행하고 Lazy<T> 타입의 객체를 반환하는 함수입니다. get() 함수가 처음 호출 되면 람다는 lazy() 함수를 통과하고 그 이후에는 결과를 기억합니다.
- 위 예시에서 보면, get() 첫 호출 이후에는 값을 기억해서 fist call이 한 번만 출력되는 것을 볼 수 있습니다.
기본적으로 lazy 프로퍼티의 연산은 동기화(synchronized)되어 진행되기 때문에, 필드에 값은 1번만 1개의 스레드에서 연산되고, 모든 스레드에서 동일한 값을 바라보게 됩니다.
- lazy() 함수는 위임 초기화와 관련하여, threadSafeMode 타입을 몇가지 제공하며, lazy() 함수 파라미터로 전달하여 사용합니다.
- LazyThreadSafetyMode.PUBLICATION: 초기화되지 않은 lazy 객체에 동시에 여러번 접근하여 초기화 함수를 호출할 수 있습니다. 하지만, 최종적으로 lazy 객체 값으로 사용되는 것은 첫 번째로 반환된 값입니다.
- LazyThreadSafetyMode.NONE: lazy 객체에 대해 동기화를 사용하지 않을 때 사용합니다. 만약, 객체에 여러 스레드가 접근할 수 있다면 사용하지 않는 것이 좋습니다. (스레드 안정성 보장 X)
- LazyThreadSafetyMode.SYNCHRONIZED(기본값): 오직 하나의 스레드만 lazy 객체에 접근하여 초기화할 수 있도록 lock을 겁니다.
observable 프로퍼티
observable 프로퍼티를 이용하면 observable 패턴을 쉽게 만들 수 있습니다. 아래 예시는 값이 변경될 때마다 이전 값과 새로운 값을 출력해주는 예시입니다.
Delegates.observable()은 2개의 파라미터를 받습니다.
- 초기값(initialValue)
- 값 변경 시 사용할 핸들러(onChange)
- 이 핸들러는 프로퍼티에 값 할당이 완료된 후 수행되며, 값이 할당될때마다 항상 호출됩니다.
다른 프로퍼티에 위임
프로퍼티는 다른 프로퍼티에 getter와 setter를 위임할 수 있습니다. top-level에 구현되거나 클래스 프로퍼티(멤버, 확장) 모두 위임이 가능합니다.
var topLevelInt: Int = 0
class ClassWithDelegate(val anotherClassInt: Int)
class MyClass(var memberInt: Int, val anotherClassInstance: ClassWithDelegate) {
var delegatedToMember: Int by this::memberInt
var delegatedToTopLevel: Int by ::topLevelInt
val delegatedToAnotherClass: Int by anotherClassInstance::anotherClassInt
}
var MyClass.extDelegated: Int by ::topLevelInt
위임 프로퍼티가 될 수 있는 항목
- top-level 프로퍼티
- 같은 class 내의 member 또는 extension 프로퍼티
- 다른 class 내의 member 또는 extension 프로퍼티
다른 프로퍼티에 위임하기 위해서는 :: 위임자를 delegate name에 붙여야(예: this::delegate 또는 MyClass::delegate) 하며, 아래와 같이 이전 버전과 호환되는 방식으로 프로퍼티의 이름을 변경하는 등의 상황일 때 유용합니다.
일반적인 usecase
map을 통한 프로퍼티 위임
operator fun <V, V1 : V> Map<String, V>.getValue(
thisRef: Any?,
property: KProperty<*>
): V1 {
val key = property.name
val value = get(key)
if (value == null && !containsKey(key)) {
throw NoSuchElementException(
"Key ${property.name} is missing in the map."
)
} else {
return value as V1
}
}
map에 프로퍼티의 값들을 저장하는 것은 위임 프로퍼티의 usecase 중 하나입니다.
JSON을 파싱하거나 동적인 작업을 수행할 때 종종 사용되며, map에 위임 프로퍼티를 이용하는 예시는 아래와 같습니다.
프로퍼티 위임 요구사항
class Resource
class Owner {
var varResource: Resource by ResourceDelegate()
}
class ResourceDelegate(private var resource: Resource = Resource()) {
operator fun getValue(thisRef: Owner, property: KProperty<*>): Resource {
return resource
}
operator fun setValue(thisRef: Owner, property: KProperty<*>, value: Any?) {
if (value is Resource) {
resource = value
}
}
}
- 프로퍼티 위임을 통해 게터와 세터를 만들 때에는 메소드 이름이 중요합니다.
- 게터: getValue
- thisRef: 프로퍼티 주인(property owner)과 동일 타입 또는 슈퍼타입
- property: KProperty<*> 타입 또는 슈퍼타입
- 반환값: 프로퍼티와 동일 타입 또는 서브타입
- 세터: setValue
- thisRef: 프로퍼티 주인(property owner)과 와 동일 타입 또는 슈퍼타입
- property: KProperty<*> 타입 또는 슈퍼타입
- value: 프로퍼티와 동일 타입 또는 슈퍼타입
- 게터: getValue
- val의 경우 getValue 연산이 필요하며, var의 경우 getValue와 setValue 연산이 모두 필요합니다.
- 프로퍼티 위임용 클래스를 생성하고 나면, 프로퍼티 위임을 할 객체를 만들고 by 키워드를 사용해서 프로퍼티 위임용 클래스와 연결하면 됩니다.
아이템 22 - 일반적인 알고리즘을 구현할 때 제네릭을 사용하라
함수에 파라미터를 통해 값을 전달할 수 있는 것처럼, 타입 아규먼트를 사용하면 함수에 타입을 전달할 수 있습니다. 타입 아규먼트를 사용하는 함수(타입 파라미터를 갖는 함수)를 제네릭 함수(generic function)라고 부릅니다. 코틀린 자료형 시스템에서 타입 파라미터는 굉장히 중요한 부분이며, 이를 활용해 type-safe 제네릭 알고리즘과 제네릭 객체를 구현합니다.
타입 파라미터는 컴파일러에 타입 관련 정보를 제공하여 컴파일러가 타입을 좀 더 정확하게 추측할 수 있게 해줍니다. 위 예시로 적어둔 filter를 보면, 타입 파라미터를 통해서 어떤 요소의 타입이 들어왔을 때 반환값은 어떤 타입이어야 하는 등의 정보를 제공해줍니다.
- 런타임 시점에는 최종적으로 타입 정보는 사라지겠지만, 개발 중에는 특정 타입임을 강제할 수 있어 보다 안전합니다.
제네릭 제한
타입 파라미터의 중요한 기능 중 하나는 구체적인 타입의 서브타입만 사용하도록 타입을 제한할 수 있다는 점입니다.
위 예시를 보면, T를 Comparable<T> 타입으로 제한함으로써 비교가 가능한 타입에 대해서만 sorted() 함수를 사용할 수 있도록 제한했습니다. 타입 제한을 통해 해당 함수를 사용할 수 없는 타입에 대해서는 미리 컴파일 시점에 오류를 내어 개발자가 수정할 수 있도록 돕습니다.
제네릭 선언하기
어떤 선언을 제네릭 선언으로 만들려면 하나 이상의 타입 파라미터를 추가해야 합니다.
제네릭 클래스 선언
class TreeNode<T>(val data: T)
- 제네릭 클래스를 선언할 때에는 클래스 이름 바로 뒤에 타입 파라미터를 넣으면 됩니다.
제네릭 함수/프로퍼티 선언
fun <T> TreeNode<T>.addChildren(vararg data: T) { ... }
val <T> TreeNode<T>.depth: Int
get() = (children.asSequence().map { it.depth }.maxOrNull() ?: 0) + 1
- 제네릭 클래스와 달리 프로퍼티나 함수를 제네릭으로 선언할 때는 타입 파라미터를 fun이나 val/var 바로 뒤에 넣습니다.
- 클래스 멤버 프로퍼티는 타입 파라미터를 가질 수 없고, 오직 확장 프로퍼티만 타입 파라티머를 가질 수 있습니다.
타입 인자 제한
기본적으로 타입 인자로 들어갈 수 있는 타입에는 아무런 제약이 없습니다. 따라서, 타입 파라미터들은 Any? 타입과 동의어로 처리됩니다. 만약, 특정 타입 또는 그 하위 타입만 받도록 하기 위해서는 상위 바운드(upper bound)를 설정하면 됩니다.
- 상위 바운드 설정 시, 자바에서 T extends Number로 작성했었으며, 코틀린에서는 T: Number로 표기합니다.
// 숫자 연산이 필요하여 upper bound를 Number로 설정
fun <T: Number>TreeNode<T>.average(): Double {
var count = 0
var sum = 0.0
walkDepthFirst {
count++
sum += it.toDouble()
}
return sum / count
}
참고로, final 클래스를 상위 바운드로 지정하면 해당 타입 말고는 사용할 수 있는 타입이 없어 쓸모가 없습니다. 이럴 때는 아래와 같이 컴파일러가 경고를 표시합니다.
타입 소거와 구체화
위 사진을 보면, 컴파일러는 data is T라는 식에 오류를 표기합니다. 그 이유는, 런타임 시점에는 타입 소거가 되어 제네릭의 타입을 알 수 없기 때문입니다.
자바 제네릭스가 자바 5부터 도입됨에 따라, 새 버전 자바 컴파일러와 가상 머신은 기존(자바 5 이전) 코드와의 하위 호환성을 위해 기존 타입 표현 방식을 유지해야 했습니다. 그 결과 JVM에서 타입 인자에 대한 정보는 코드에서 지워져 소스코드의 List<String>과 List<Number>와 같은 타입은 JVM상에서 List라는 동일한 타입으로 합쳐집니다.
코틀린에서는 제네릭스가 1.0 버전부터 존재했지만, JVM이 주요 플랫폼이기 때문에 자바와 같은 타입 소거 문제가 생겼습니다.
아이템 23 - 타입 파라미터의 섀도잉을 피하라
아래 코드처럼 지역 파라미터가 외부 스코프에 있는 파라미터와 같은 이름을 가질 수 있습니다. 이렇게 되면, 지역 파라미터가 외부 스코프의 파라미터를 가리게 되며, 이를 섀도잉(shadowing)이라고 부릅니다.
class Forest(val name: String) {
fun addTree(name: String) {}
}
섀도잉 현상은 아래 예시처럼 클래스 타입 파라미터와 함수 타입 파라미터 사이에서도 발생할 수 있습니다.
- 왼쪽 예시의 경우,
Forest
와addTree
의 타입 파라미터는 독립적으로 동작합니다.- 만약, 제네릭을 제대로 이해하지 못했다면, 타입 파라미터 이름이
T
로 동일하게 클래스와 함수에 지정해서addTree
가Forest
의 타입 파라미터를 사용하게 하는 것으로 오해할 수도 있습니다.
- 만약, 제네릭을 제대로 이해하지 못했다면, 타입 파라미터 이름이
- 오른쪽 예시의 경우,
addTree
에는 함수의 타입 파라미터를 명시하지 않고T
를 사용했기 때문에 해당 함수가 속한 제네릭 클래스의 타입 파라미터를 사용합니다.
타입 파라미터에서 섀도잉이 발생할 경우 이해하기 어려울 수 있으니, 주의해서 코드를 작성해야 합니다.
아이템 24 - 제네릭 타입과 variance 한정자를 활용하라
제네릭 타입을 사용할 때 타입 파라티머에 variance 한정자(out 또는 in)를 붙이지 않으면 기본적으로 invariant(불공변성)입니다. invariant란, 제네릭 타입으로 만들어지는 타입들이 서로 관련성이 없다는 의미입니다.
공변이라는 말은 타입 파라미터의 상하위 타입 관계에 따라 제네릭 타입의 상하위 타입 관계가 함께 변한다는 뜻입니다.
만약, 제네릭 타입으로 만들어지는 타입들이 서로 관련성이 있으려면 out 또는 in이라는 variance 한정자를 붙여야 하며, out을 붙이면 covariant(공변성), in을 붙이면 contravariant(반변성)를 만들 수 있습니다.
기본적으로 Int는 Number의 하위 타입입니다. 이때, variance 한정자를 어떻게 나타내느냐에 따라 위 그림처럼 Int와 Number간 관계가 달라집니다.
함수 타입
함수 타입은 파라미터 유형과 리턴 타입에 따라 서로 관계를 갖게 됩니다.
fun funType(transition: (Int) -> Any) {
print(transition(42))
}
예를 들어, Int를 받아 Any를 리턴하는 함수를 파라미터로 받는 함수가 있다고 가정합니다. 해당 함수의 파라미터로 들어올 수 있는 (Int) -> Any 타입의 함수는 다음과 같습니다.
- 함수 파라미터 타입은 Int -> Number -> Any 순으로 슈퍼타입이 되며, 함수 리턴 타입은 Any -> Number -> Int 순으로 슈퍼타입이 됩니다.
위와 같은 타입으로 들어갈 수 있는 이유는 타입들에 아래와 같은 관계가 있으며 함수 타입을 사용할 때는 자동으로 variantance 한정자가 사용됩니다.
- 함수 타입의 모든 파라미터 타입: contravariant
- 함수 타입의 모든 리턴 타입: covariant
variance 한정자의 안정성
자바의 배열은 covariant 속성을 갖기 때문에 문제가 있습니다.
Integer[] numbers = {1, 4, 2, 1};
Object[] objects = numbers;
objests[2] = "B"; // 런타임 오류: ArrayStoreException
위 예시는 자바 배열이 covariant 속성을 갖기 때문에 발생할 수 있는 문제를 나타내고 있습니다. Interger[] 값을 Object[]으로 캐스팅해도 covariant하기 때문에 컴파일러는 문제가 없다고 판단합니다. 하지만, 업캐스팅을 하였다고 해도 실질적인 객체 타입이 바뀌는 것은 아니기 때문에 String 값을 Integer[]에 넣을 수 없어 런타임 시 오류가 발생합니다.
이는 자바의 결함으로, 코틀린에서는 이런 결함을 해결하기 위해 Array(IntArray, CharArray 등)를 invariant로 만들었습니다.
open class Dog
class Puppy: Dog()
class Hound: Dog()
fun takeDog(dog: Dog) {}
takeDog(Dog())
takeDog(Puppy()) // 암묵적 업캐스팅
takeDog(Hound()) // 암묵적 업캐스팅
위 예시를 보면, 코틀린에서는 파라미터 타입을 예측할 수 있다면, 서브타입을 전달할 수 있음을 알 수 있습니다. 이는 covariant 타입 파라미터(out 한정자)가 in 한정자 위치(예: 함수 파라미터)에 있다면, covariant와 업캐스팅을 연결해서 타입을 전달할 수 있음을 의미합니다.
하지만, 업캐스팅 후 실질적인 객체는 그대로 유지되기 때문에 안전하지 않습니다.
따라서, 위 예시처럼 코틀린에서는 public in 한정자 위치에 covariant 타입 파라미터(out 한정자)가 오는 것을 금지하고 있습니다. 이때, public in 한정자를 private in으로 변경하면 오류가 발생하지 않습니다.
- 객체 내부에서는 업캐스트 객체에 covariant(out 한정자)를 사용할 수 없기 때문입니다.
covariant와 public in과 같은 문제는 contravariant 타입 파라미터(in 한정자)와 public out 위치(함수 리턴 타입 또는 프로퍼티 타입)에서도 발생합니다. out 위치는 암묵적인 업캐스팅을 허용합니다.
코틀린은 contravariant 타입 파라미터(in 한정자)를 public out 한정자 위치에 사용하는 것을 금지하고 있습니다. 이 역시, public out 한정자를 private out으로 변경하면 오류가 발생하지 않습니다.
variance 한정자 위치
variance 한정자는 크게 두 위치에서 사용할 수 있습니다.
- 선언 부분
- 클래스와 인터페이스가 사용되는 모든 곳에 영향을 주며, 일반적으로 이 위치에서 사용합니다.
- 클래스와 인터페이스를 활용하는 위치
- 모든 인스턴스가 아닌 특정 인스턴스에만 적용해야 할 때 사용합니다.
아이템 25 -공통 모듈을 추출해서 여러 플랫폼에서 재사용하라
일반적으로 기업이 하나의 플랫폼만을 대상으로 어플리케이션을 만드는 경우는 없습니다. 예를 들어, 앱 서비스를 한다고 해도 iOS와 안드로이드를 모두 지원하는 경우가 대부분입니다. 다른 플랫폼에 동일한 제품을 구현한다면, 재사용할 수 있는 부분이 있을 것이기 때문에 해당 로직은 공통으로 추출하여 관리하면 좋습니다.
스터디 준비
궁금한 점
- 아이템 19
- DRY의 범위는 얼마만큼인가? 팀안의 로직에서만 DRY일까? 사내 DW의 데이터를 가져와서 각 팀마다 저장하는게 맞는건가?
- 데이터 목적에 따라 다를 듯 → 각 팀마다 가공해서 쓸 것이라면, 직접 데이터 적재하는게 맞고 주어진 데이터 그대로 쓴다면 API로 제공해도 될 듯
- MSA에서 다른 팀 DB를 직접 접근하는게 맞을까? 사내 DW라서 문제 없는 것일까?
- DRY의 범위는 얼마만큼인가? 팀안의 로직에서만 DRY일까? 사내 DW의 데이터를 가져와서 각 팀마다 저장하는게 맞는건가?
- 아이템 21
- P127) 컨텍스트(this)와 프로퍼티 레퍼런스의 경계도 함께 사용하는 형태로 바뀐다는 것의 의미?
- P127) 프로퍼티가 톱레벨에서 사용될 때는 this 대신 null로 바뀐다는 것이 주는 특별한 의미가 있는것인지?
- 아이템 24
- 한정자를 실제로 사용해본 경험이 있으신지? 있으시다면, 어떨때 사용하셨는지? Util성 클래스 만들 때 사용하는지?
- P140) 하단 문단이 전체적으로 이해가 가지 않음
고찰
- 아이템 19
- 함께 변경될 가능성이 높고 여러 요소에 비슷한 부분이 있어, 하나의 공통 로직으로 뺀 적이 있습니다.
- 다만, 공통 로직으로 뺄 때 일부 요소의 차이로 인해, 오히려 파라미터가 헷갈릴 여지가 있어 기존 로직이 더 익숙하다는 의견을 들었습니다.
- 반복을 줄이는 것은 좋지만, 해당 반복을 줄임으로 인해 독이 될 수 있는 점을 잘 생각하고 가독성과 재사용성을 함께 가져갈 수 있도록 고민해야겠다는 생각이 들었습니다.
- 아이템 20
- 확장 함수의 장점으로 자동 완성 기능으로 해당 함수가 있다는 것을 알려주는 것은 좋지만, 간혹 stdlib에 존재할 것 같은 이름으로 프로젝트 내부에 확장 함수를 만들면 기능을 착각할 여지가 있었습니다. 확장 함수를 구현할 때에는 이 부분을 염두하고 진행하면 좋을 것 같습니다.
참고 자료
- 이펙티브 코틀린(마르친 모스칼라 저)
- Generics in Kotlin | Baeldung
- kotlin docs - Generics: in, out, where
- kotlin docs - delegated properties
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Effective Kotlin] 4장 추상화 설계 (0) | 2024.07.09 |
---|---|
[Kotlin Coroutine] 코루틴과 멀티스레딩 환경에서의 최적화 (0) | 2024.07.04 |
[Kotlin Coroutine] kotlinx-coroutines-test를 활용한 coroutine 테스트 (0) | 2024.06.29 |
[Kotlin Coroutine] 고급 코루틴 구조 및 패턴 이해 (0) | 2024.06.27 |
[Effective Kotlin] 2장 가독성 (0) | 2024.06.27 |