코틀린 입문 스터디 Week3 자료

mook2_y2
45 min readFeb 10, 2019

--

스터디파이 코틀린 입문 스터디 (https://studypie.co/ko/course/kotlin_beginner) 3주차 관련 자료입니다.

코틀린 입문반은 Kotlin을 직접 개발한 개발자가 진행하는 Coursera 강좌인 “Kotlin for Java Developers” (https://www.coursera.org/learn/kotlin-for-java-developers) 를 기반으로 진행되며 아래는 본 강좌 요약 및 관련 추가 자료 정리입니다.

Properties

1. Properties

  • Properties란 클래스내에 정보를 실제로 담고 있는 변수 (field)에 직접적으로 접근하는 것이 아니라, accessor를 통해 간접적으로 접근하도록 하여, 클래스 정보에 대한 접근을 제어할 수 있도록 하는 (ex: read-only, data validation 등) 방식이다. (관련 링크 : 위키피디아 — Properties, C# — Field와 Property의 차이)
  • Java는 properties를 언어적인 기능으로 지원하지는 않으며 JavaBeans 컨벤션에 따라 개발자가 getter/setter를 직접 구현하는 방식으로 사용할 수 있다. 한편, Kotlin은 properties를 기능으로써 지원하여 문법적으로 간결하게 사용할 수 있으며, 필요에 따라 커스터마이즈할 수 있다. (관련 링크 : 위키피디아 — JavaBeans)
  • Kotlin에서 클래스를 정의할 때 클래스 내부에 변수를 val로 정의하면 Read-only property (getter)가, var로 정의하면 Mutable property (getter/setter)가 자동으로 생성된다. 한편, 이 경우 내부적으로는 Kotlin 컴파일러에서 자동으로 getter/setter 메소드를 구현해주는 것으로 Java와 Koltin의 Property 동작 방식은 동일하며 단 코딩을 간소화해준다. 또한, Java에서 구현한 Property를 Kotlin에서 사용할 수 있고, Kotlin에서 구현한 Property를 Java에서 사용할 수 있다.
// Kotlin 코드
class Person(val name: String, var age: Int)
// 대응 되는 Java 코드 (자동으로 getter/setter가 생성함)public final class Person {
@NotNull
private final String name;
private int age;
@NotNull
public final String getName() {
return this.name;
}
public final int getAge() {
return this.age;
}
public final void setAge(int var1) {
this.age = var1;
}
...
}
  • Properties를 field없이 정의할 수 있다. 예를 들어 다른 field들을 조합하여 Class에 대한 유의미한 정보를 정의할 수 있는 경우 backing field 없이 properties를 정의하면 불필요한 메모리 할당을 줄이고 해당 정보가 필요한 경우에만 연산을 수행시킬 수 있다. (아래 코드 참고)
class Rectangle(val height:Int, val width:Int){
// Rectangle 클래스의 정사각형 여부 정보를 담는 isSquare Property
// backing field없이 height와 width field를 통해 정의
val isSquare: Boolean get(){
return height == width
}
}
fun main(){
val rectangle = Rectangle(10, 10)
// 실제 field가 없지만 아래와 같은 형태로 접근 가능
println(rectangle.isSquare)
}
  • Kotlin에서는 field 정의시 자동으로 accessor가 구현되어 클래스 외부에서는 field에 직접적으로 접근할 수 없도록 한다. <객체명>.<필드명> 형태로 접근하더라도 Kotlin 컴파일러가 자동으로 getter/setter로 변환한다. 하지만 클래스 내부의 accessor에서 field 키워드를 사용한 경우에는 field에 직접적인 접근이 가능하다. (아래 코드 참고) 또한, 클래스 내부 메소드에서 <객체명>.<필드명> 형태로 접근한 경우 accessor를 커스터마이징한 경우 accessor로 (ex: this.setState()), 커스터마이징 하지 않은 경우 field로 (ex: this.state) Kotlin 컴파일러가 선택적으로 변환한다.
class StateLogger {
var state = false
set(value) {
// accessor 내부에서 field 키워드로 접근시 Java코드상에서
// this.state가 호출된다.
println("state has changed: $field -> $value")
field = value
}

fun printState(){
// 클래스 내부 메소드에서 field에 접근시
// getter는 커스터마이징하지 않았으므로, this.state가 호출된다.
println("$state")
}
fun setOppositeState(bool: Boolean){
// setter는 커스터마이징 했으므로, this.setState()가 호출된다.
state = !bool
}
}
  • setter 정의시에 private 키워드를 통해 클래스 내부에서만 setter에 접근 가능하고, 클래스 외부에서는 setter에 접근할 수 없도록 visibility를 제어할 수 있다.
class LengthCounter{
var counter: Int = 0
private set
// 아래와 같이 클래스 내부 메소드를 통해 setter 접근 가능.
fun addWord(word: String){
counter += word.length
}
}
fun main(){
val lengthCounter = LengthCounter()
// 아랫줄 코드는 the setter is private라는 메세지와 함께 오류가 뜬다.
lengthCounter.counter = 1
}
  • 위와 같은 문법들을 통해 클래스 정보에 대한 접근 (read/reassign)을 목적에 맞게 제어할 수 있다.

2. Unstable val 실습 코드 작성시 고려할 점

  • Properties의 경우 내부적으로는 메소드가 실행되는 것이므로 properties를 호출할 때마다 메소드 내부에 구현한 연산이 매번 수행된다. 그렇기 때문에 properties는 val로 정의하더라도 getter 구현방식에 따라 호출시마다 값이 달라질 수 있다. 즉, val은 엄밀히 따지면 read-only (reassign이 안됨) 를 뜻하는 것이며, immutable한 것은 아니다. val로 선언한 properties는 mutable(unstable) 할 수 있다.
  • getter 호출시마다 값이 달라지는 사례 : Random 함수를 쓰는 경우, getter 내부에 field의 값을 변경시키는 로직을 넣는 경우

3. More about properties

Interface properties와 interface properties의 smart casts

  • Interface란? (관련 링크 : 인터페이스와 추상클래스, 인터페이스 — 점프 투 자바)
  • Interface에도 Property를 정의할 수 있는데, 이는 컴파일시에 구현되지 않은 accessor 메소드(val의 경우 getter only, var의 경우 getter&setter)들로 변환된다. 추후 Interface를 구현 (implement)하는 Class에서 이 accessor 메소드들을 override키워드와 함께 재정의해줘야 한다.
interface User {
val nickname: String
}
class FacebookUser(val accountId: Int) : User {
override val nickname = getFacebookName(accountId)
// 함수의 반환값을 field에 저장해두고, getter 호출시 field를 그대로 반환한다.
}
class EmailUser(val email: String) : User {
override val nickname : String
get() = email.substringBefore('@')

// getter를 커스터마이징 하여, getter 호출시마다 내부 로직이 수행된다.
}
  • 한편, Interface properties는 구현되지 않은 accessor 메소드들이므로, 무조건 추후 오버라이드를 통해 재정의 된다. 즉, Interface properties의 accessor 메소드는 non-final이며 (관련 링크 : 위키피디아 — final(Java) 중 Final methods 부분) 이를 Kotlin에서는 open이라 부른다.
  • properties의 타입이 interface인 경우, 해당 properties가 실제로 어떤 class로 구현된 것인지 타입 체크 ( is )가 필요할 수 있다. 하지만 이 경우, smart casts 기능이 동작하지 않는 3가지 상황이 존재한다. 1) interface 타입인 properties의 getter를 커스터마이징한 경우, 위 Unstable val 실습 코드 작성시 고려할 점 에서 살펴본 것처럼 getter 호출시마다 값이 mutable할 수 있는데 이 경우 타입 체크를 했더라도 다시 호출할 경우 다른 구현 class 타입이 반환될 가능성이 있으므로 동일 타입을 보장할 수 없다. 2) interface 에서 interface 타입 properties를 정의한 경우 해당 properties의 accessor 메소드는 open이며 재정의 과정에서 getter가 커스터마이징되어 1)과 동일한 이슈가 발생할 수 있다. 3) interface 타입 properties를 mutable로 선언한 경우 setter를 통해 값이 변경될 가능성이 있다. (아래 코드 참고) 이 경우에 대한 방안은 local variable에 getter 메소드 반환 값을 저장하여 사용하는 것이다.
// 아래에 정의한 User interface를 properties로 사용하는 경우 
interface User {
val nickname: String
}
// 1. Interface 타입의 property의 getter를 커스텀한 경우
class SessionI{
val user: User
get(){
return TODO()
}
}
// 2. Interface에서 Interface 타입 property를 정의한 경우
interface SessionII{
val user: User
}
// 3. Interface 타입의 property를 mutable(var)로 선언한 경우
class SessionIII(var user:User)
fun analyzeUserSession(session: Session, sessionII: SessionII, sessionIII: SessionIII){
/*
아래 println 두줄 모두 is a property that has open or custom getter 메시지와 함께 오류가 뜸
*/
if(session.user is FacebookUser){
println(session.user.accountId)
}
if(sessionII.user is FacebookUser){
println(sessionII.user.accountId)
}
/*
아래 println 의 경우 is a mutable property that could have been changed by this time 메시지와 함께 오류가 뜸
/*
if(sessionIII.user is Facebookuser){
println(sessionIII.user.accountId)
}
// 아래처럼 local variable에 getter의 값을 저장하는 경우 smart casts 동작
val userI = session.user
if (userI is FacebookUser) { println(userI.accountId) }
val userII = sessionII.user
if (userII is FacebookUser) { println(userII.accountId) }
val userIII = sessionIII.user
if (userIII is FacebookUser) { println(userIII.accountId) }
}

Extension properties (확장 프로퍼티)

  • 앞서 배운 Kotlin 확장 함수 (Extension functions)와 더불어, 확장 프로퍼티 (Extension properties)를 정의할 수 있다. 확장 함수는 해당 클래스에 대해 빈번히 사용되는 연산 작업이며, 확장 프로퍼티는 해당 클래스에 대해 빈번히 사용되는 정보와 속성을 저장한다.
  • 한편, 앞서 배운 것처럼 properties 호출은 결국 accessor 메소드를 호출하는 것이기 때문에 정보를 의미하지만 사실상 메소드가 호출되는 것이며 확장 함수와 동일한 개념으로 동작한다.
  • var 키워드로 선언할 경우 할당 및 재할당이 가능한 mutable한 확장 프로퍼티를 정의할 수 있다.
// 특정 String 타입 객체를 n번 반복하여 반환하는 repeat 확장함수
fun String.repeat(n: Int): String {
val sb = StringBuilder(n * length)
for (i in 1..n){
sb.append(this)
}
return sb.toString()
}
// 특정 String 타입 객체의 마지막 index값을 반환하는 lastIndex 확장프로퍼티
val String.lastIndex : Int
get() = this.length - 1
// 특정 StringBuilder 타입 객체의 마지막 글자를 반환하고, 마지막 글자를 재할당할 수 // 있는 lastChar 확장 프로퍼티
var StringBuilder.lastChar: Char
get() = get(this.length - 1)
set(value: Char){
this.setCharAt(length-1, value)
}
  • 위와 같은 방식을 통해 확장 프로퍼티는 (확장 함수와 마찬가지로) 해당 클래스에서 빈번하게 사용되고 해당 클래스의 성격을 표현하는 속성 정보를 정의할 수 있도록 한다.

4. Lazy or late initialization

Lazy property

  • 프로그래밍에서 Lazy란 계산의 결과값이 필요할 때까지 계산을 늦추는 계산 기법을 뜻한다. (관련 링크 : 위키백과 — Lazy evaluation) 로직 중 복잡한 계산 작업이 필요한 결과값이 특정한 조건에서만 필요한 경우 Lazy evaluation을 통해 성능을 최적화할 수 있다.
  • Kotlin에서는 by lazy 키워드를 통해 Lazy Properties를 정의할 수 있다.
  • 아래 코드의 3가지 방식을 비교해보면, 1) lambda를 run 통해 실행시키는 경우 변수 호출 여부와 무관하게 무조건 연산을 수행하여 반환 값을 저장한다. 2) property의 경우 호출되기 전까지는 실행되지 않지만, 호출 시마다 매번 연산이 수행된다. 3) lazy property의 경우 호출되기 전까지는 실행되지 않으며, 최초 1회 호출시에만 연산이 수행되고 반환 값을 저장한다.
// 1. lambda를 run을 통해 실행시키는 경우
val ValueI : String = run {
println("computed! - run lambda")
"Hello"
}
// 2. property를 사용하는 경우
val ValueII : String
get() {
println("computed! - property")
return "Hello"
}
// 3. lazy property를 사용하는 경우
val ValueIII : String by lazy {
println("computed! - lazy property")
"Hello"
}

Late initialization

  • 특정한 목적에 따라 Property를 클래스 생성자 (Constructor)가 아니라 특정한 다른 지점에서 초기화해야 하는 상황에 유용한 기능이다. 사용되는 예시로 안드로이드 액티비티, JUnit 테스트, 의존성 주입 등이 있다. (관련 링크 : Medium 블로그 중 lateinit 부분)
  • 이러한 상황에 대한 Java 대응 방식은 생성자에서 우선 null로 초기화했다가 추후 특정 지점에서 다시 재할당하는 방식이다. 하지만 이 경우 해당 변수를 Nullable type으로 선언해야 하며 이로인해 이 변수를 다루는 과정에서 계속 Nullability 관련 연산자를 사용해야 하는 비효율성이 발생한다. 이 경우 변수 앞에 lateinit 키워드를 써주면 Non-nullable 타입으로 처리할 수 있다.
  • Late initialization은 사실 변수 선언시 null로 초기화했다가, 개발자가 실제로 유의미한 초기화를 하는 특정 지점에서 다시 재할당을 하는 방식으로 컴파일 된다. 이에 따라 lateinit 키워드는 val 키워드에는 사용할 수 없도록 언어적으로 제한되어 있다.
  • Late initialization은 불필요한 Nullability 관련 코드를 제거해주는 목적의 문법이므로 lateinit 키워드로 선언된 변수는 Nullable type이 될 수 없도록 제한되어 있다. 또한 Primitive type (Int, Boolean, Char 등)의 경우 개발자가 구현하는 의도에 맞춘 디폴트 값을 주는 것이 가능하므로 (ex: 0, false, ‘’ 등) lateinit 키워드를 사용할 수없도록 제한되어 있다.
  • lateinit 키워드로 선언된 변수의 초기화 여부를 .isInitialized를 통해 체크할 수 있다. 이는 컴파일 과정에서 null 여부를 확인하는 if 조건문으로 구현된다.
  • 위와 같이 late initialization은 사실상 Java 방식과 동일하게 동작한다. 다만, 실용적 관점에서 클래스 생성자가 아닌 특정 지점에서만 변수 초기화가 가능하고, 실제로 해당 변수는 그 지점 이후에만 역참조되는 경우들이 있는데 (ex: 안드로이드 액티비티 onCreate) 이 때 단순 반복적인 코드를 Kotlin 컴파일러 레벨에서 자동으로 생성해주어 Kotlin 코드 간결성을 개선해 준다는 의미가 있다.

5. Using lateinit property 실습 코드 작성시 고려할 점

  • late initialization은 앞서 배운 Non-null assertion (!! )과 유사하게 개발자가 해당 변수가 반드시 초기화 이후에 역참조될 것이라는 확신이 있을 때만 사용해야 하는 것으로, (ex: 안드로이드 액티비 onCreate) 초기화 전에 역참조가 발생할 경우 UninitializedPropertyAccessException 이라는 런타임 에러가 발생한다. 다만, NullPointerException과 달리 에러 문구에서 정확히 어떤 변수에서 문제가 발생했는지 명시적으로 알려주므로 오류 수정시 상대적으로 용이하다는 장점이 있다.

Object-oriented Programming

1. OOP in Kotlin

  • OOP와 관련한 가시성 (visibility), 상속 (Inheritance)과 오버라이딩 (overriding), 디렉토리 구조를 Kotlin에서 어떻게 처리하는지에 대해 다룬다. Java와 비교해 새롭게 도입된 개념은 없으며 실용적 측면에서 개선점들이 있다.
  • 가시성 (visibility)은 클래스 멤버 (필드, 메소드)의 사용 범위를 결정하는 것을 뜻한다. Java에는 public, protected, package private, private 의 4가지 수준이 존재하며, 디폴트는 package private로 이에 대한 접근 제어자 (access modifier) 키워드는 없다. (관련 링크 : Java 가시성의 개념) 한편, Kotlin은 public, protected, internal, private의 4가지 수준이 존재하며, 디폴트는 public이다. Kotlin에는 기존 Java의 private package 가시성이 없으며, 동일 모듈 내에서 접근 가능한 internal 개념이 추가되었다. (관련 링크 : 커니의 코틀린::클래스 및 인터페이스 중 접근 제한자 부분)
  • 상속 (Inheritance)는 OOP에서 “is/kind of” 관계 (ex: 개 (자식클래스)는 동물 (부모클래스)이다.) 를 표현하는 개념이다. (관련 링크 : 나무위키-상속(프로그래밍) ) 오버라이딩 (overriding)은 자식클래스가 상속받은 부모클래스의 메소드를 특정한 형태로 재구현하는 것을 뜻한다. (관련 링크 : 위키피디아 — 메소드 오버라이딩) 이에 대해 Java는 디폴트는 open (오버라이딩/상속 가능)이며, final 키워드를 사용하는 경우 오버라이딩/상속 할 수 없도록 제한할 수 있다. 한편, Kotlin은 디폴트는 final (오버라이딩/상속 불가) 이며, open 키워드를 사용하는 경우 오버라이딩/상속이 가능하도록 허용할 수 있다.
  • Java에서는 오버라이딩시에 @Override 어노테이션을 사용하며 필수는 아니지만, Kotlin에서는 오버라이딩시에 override 키워드를 사용하고 의무적으로 선언하는 것을 강제한다.
  • 디폴트 설정 (제어자(modifier)를 선언하지 않은 경우의 기본 설정)을 무엇으로 할지는 중요한 문제이며 Kotlin이 가시성과 상속/오버라이딩 가능 여부에 대해 Java와 다른 디폴트 설정을 둔 (package private -> public, open -> final) 의도는 가장 일반적으로 사용되며, 가능한 많은 기능을 사용할 수 있고, 리스크가 적은 설정 수준이 가시성은 public, 상속/오버라이딩은 final이라 판단했기 때문이다. 특히 상속/오버라이딩 가능 여부의 경우 라이브러리 개발자 입장에서 이미 출시한 코드를 open에서 final로 변경하는 것은 해당 라이브러리 기반으로 작성된 코드에 영향을 주므로 수정하기 어렵지만, final에서 open으로 변경하는 것은 상대적으로 문제가 적다.

2. Constructors, Inheritance syntax

Constructors

  • Kotlin은 단순한 프로퍼티와 단순한 로직의 단일 주 생성자 (primary constructor)를 가진 클래스를 정의할 때 아래와 같이 간결한 문법으로 정의할 수 있다. Java 에서는 직접 모두 작성해야했던 다소 귀찮은 단순 코드를 생략할 수 있도록 해준다.
// name과 age 프로퍼티를 생성시에 초기화하는 클래스에 대한 Kotlin 문법
class Person(val name:String, var age:Int)
// 위 Kotlin 문법에 대응되는 Java 코드
// 위에서 배운 프로퍼티 accessor에 대한 구현과 생성자 구현을 모두 직접해야 함
public final class Person {
private final String name;
private int age;

public final String getName() { return this.name;}

public final int getAge() { return this.age; }

public final void setAge(int age) { this.age = age; }

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
  • 보다 복잡한 생성자 로직이 필요한 경우, 클래스명 뒤 괄호에 val/var 키워드를 빼고, init { } 을 통해 생성자 부분을 커스터마이징할 수 있다. 또한, 생성자의 가시성을 디폴트인 public이 아닌 다른 것으로 변경하고자 하는 경우 클래스명과 괄호 사이에 <가시성 접근제어자> constructor 를 써서 변경할 수 있다.
class Person(val name:String, var age:Int)// 위 코드와 동일한 의미를 가지며 생성자 로직을 커스터마이징할 수 있는 문법
class Person(name:String, age:Int){
val name:String
var age:Int
init {
this.name = name
this.age = age
}
}
// 위 코드와 동일한 의미를 가지나, 생성자에 대한 가시성을 변경하는 문법
class Person
internal constructor(name: String, age:Int){
val name:String
var age:Int

init {
this.name = name
this.age = age
}
}
  • 생성자 오버로딩 (생성자 매개변수의 유형과 개수를 다르게 하는 여러 생성자를 구현하는 것) 이 필요한 경우 클래스 내부에 constructor 를 통해 Secondary Constructor를 정의할 수 있다. 단, secondary constructor 는 반드시 Primary constructor 또는 다른 secondary constructor를 호출해야 한다.
// 위 코드 블록 3번째 코드에 secondary constructor를 구현한 예시 
class Person
internal constructor(name: String, age:Int){
val name:String
var age:Int

init {
this.name = name
this.age = age
}

constructor(name: String, birthYear:Int, nowYear:Int) : this (name, nowYear-birthYear+1)
}
// 아래와 같이 상황에 따라 다른 매개변수 유형과 개수로 객체 생성 가능
val person1 = Person("kevin", 28)
val person2 = Person("kevin", birthYear = 1992, nowYear = 2001)

Inheritance Syntax

  • 앞서 언급한대로 Kotlin에서 상속 가능 여부에 대한 디폴트 설정은 final 이므로, 부모클래스로 사용하기 위해서는 class 앞에 open 제어자를 선언해야 한다.
  • Java에서는 부모클래스 상속시에는 extends , 인터페이스 구현시에는 implements 로 서로 다른 문법을 사용한다. 하지만 Kotlin에서는 부모클래스 상속과 인터페이스 구현 모두 : 라는 동일한 문법을 사용한다.
  • 자식클래스에서 부모클래스의 멤버에 접근하기 위해 super 키워드를 사용한다.
interface Base
class BaseImple : Base // 인터페이스 구현하는 경우

open class Parent(val name: String){ // 부모클래스 사용을 위해 open 선언
open fun getAddress():String{ return TODO() }
class Child(name: String): Parent(name) // 부모클래스 상속하는 경우
class Child: Parent {
// 자식클래스에 secondary constructor를 정의하며 부모클래스 생성자 호출
constructor(name: String, param: Int) : super(name)
// 자식클래스에서 부모클래스 멤버 메소드 오버라이딩하며 부모클래스의 것 호출
override fun getAddress(): String { return super.getAddress() }
}

3. NPE during initialization 실습 코드 작성시 고려할 점

  • Java에서 상속관계인 클래스들의 생성자 호출 순서는 자식클래스 생성자 호출시 우선 부모클래스 생성자가 호출되며 부모클래스 생성자 내부 로직 실행 -> 자식 클래스 생성자 내부 로직 실행의 순서로 실행된다. (관련 링크 : Java 상속관계 생성자 호출 순서) Kotlin 역시 이와 동일하게 동작한다.
open class A(open val value: String) {
init {
println(value.length)
}
}

class B(override val value: String) : A(value)

fun main(args: Array<String>) {
B("a")
}
  • 한편 부모클래스를 상속하는 과정에서 위 코드와 같이 프로퍼티 부분에 override val/override var 를 선언할 경우, 자식클래스에서 추가적인 필드를 생성하며 (변수명은 같지만 다른 reference) 연관된 accessor를 오버라이드하여 자식클래스에 생성한 필드에 대해 동작하도록 구현된다.
  • B 객체를 생성하는 과정에서 B 클래스 생성자가 호출되고, super(value); 로 인해 A클래스 생성자가 호출된다. 이 때 this.value = value;라인은 오버라이딩 되지 않았으므로 A 클래스 value 필드를 초기화한다. 한편, 그 이후 System.out.println(this.getValue().length()); 라인이 수행되는데 이 때 getValue() 는 오버라이딩 되었으므로 오버라이드 메소드가 호출되어 B 클래스 value 필드를 역참조하게 되고, 이 필드는 아직 초기화가 되어 있지 않으므로 NullPointerException이 발생한다. 이는 Kotlin 상에서 NullPointerException을 컴파일 단계에서 잡지 못하는 사례로 다만 역참조 하는 부분에 Accessing non-final property value in constructor 라는 경고 메시지가 노출된다.
// 위 Kotlin 코드가 실제로 동작하는 방식 (Java 코드)
public class A {
private final String value;
public String getValue() { return this.value;} public A(String value) {
this.value = value;
System.out.println(this.getValue().length());
}
}
public final class B extends A {
private final String value;

@Override
public String getValue() { return this.value; }

public B(String value) {
super(value);
this.value = value;
}
}

4. Class modifiers — I, II

  • Kotlin은 빈번하게 사용되는 유스케이스들을 위한 class modifiers를 제공한다. enum , data , sealed , inner 이며 modifiers를 선언할 경우 컴파일 되는 과정에서 사용되는 목적을 지원하는 메소드를 자동으로 생성하거나 추가적인 제약을 더해준다.

Enum class

  • Enum은 Enumeration (목록)을 의미하며, Java와 동일하게 고정된 갯수의 값들로 구성된 목록이 필요할 때 사용한다.
import Color.*enum class Color{
BLUE, ORANGE, RED
}
fun getDescription(color : Color) =
when (color) {
BLUE -> "cold" // 맨 윗줄 import가 없을 경우 Color.BLUE로 접근한다.
ORANGE -> "mild"
RED -> "hot"
}
  • 위 코드와 같이 Kotlin 에서 Enum 을 다루는 효과적인 방식은 when expression 을 사용하는 것이다.
import Color.*// 색상 목록의 각 색상 항목마다 서로 다른 r, g, b 속성 정보를 가짐 -> 프로퍼티로 구현
enum class Color(val r:Int, val g:Int, val b:Int){
BLUE(0,0,255),
ORANGE(255,165,0),
RED(255,0,0); // 세미콜론을 통해 Enum list와 member list를 구분
// 색상 목록의 각 색상 항목마다 rgb값을 구하는 연산이 필요 -> 메소드로 구현
fun rgb() = (r * 256 + g) * 256 + b
}
fun main(args: Array<String>){
println(BLUE.r)
println(BLUE.rgb())
}
  • 한편 위 코드와 같이 Enum class 정의시 프로퍼티와 메소드를 함께 정의할 수 있다. 프로퍼티는 목록의 각 항목마다 특정한 속성 정보를 담는 경우 유용하며, 메소드는 목록의 각 항목에 대해 빈번하게 수행되는 연산이 있는 경우 유용하다.

Data class

  • Enum class 가 고정된 목록을 다룬다면, Data class는 특정한 구조로 구성된 데이터들을 다룰 때 사용된다. 예를 들어, enum class가 적합한 예시는 고정된 갯수로 존재하며 추가/수정이 없는 “색상 목록”이며, data class가 적합한 예시는 구조는 특정하나 빈번히 추가/수정될 수 있는 “연락처 목록”이다.
  • Data class는 copy() , equals() , hashCode() , toString() 등의 메소드가 컴파일 단계에서 자동으로 생성되어 데이터를 추가/복사하거나, 비교하거나, 조회하는 등 데이터를 다룰 때 빈번하게 사용되는 기능이 지원된다.
data class Contact(val name:String, val address:String)fun main(args: Array<String>){
val contact1 = Contact("Kevin", "Seoul")
val contact2 = contact1.copy()
val contact3 = contact1.copy(adress = "Busan")
}
  • copy() 는 매개변수 없이 호출할 경우 동일한 데이터를 복사하여 반환하며, 매개변수를 넣을 경우 해당 부분만 변경한 데이터를 복사하여 반환한다.
fun main(args: Array<String>){
val contact1 = Contact("Kevin", "Seoul")
val contact2 = contact1.copy()
val contact3 = contact1.copy(adress = "Busan")
}
  • Java 에서 == 비교 연산자는 reference equality를 의미하므로 Reference type 객체 구성 내용을 비교하기 위해서는 equals() 메소드를 사용해야 한다. (관련 링크 : Java equals와 ==의 차이점) 이와 달리, Kotlin에서 == 연산자는 컴파일 과정에서 equals() 메소드로 변환되며, === 연산자가 reference equality를 체크한다. 한편 개발자가 직접 구현하는 클래스의 경우 의도에 적합하도록 equals() 를 재정의해주어야 하는데 data class로 선언하는 경우 자동적으로 구성 요소가 모두 동일한지 비교하는 메소드를 생성해준다.

Sealed class

  • Sealed class는 고정된 상속 계층 구조를 가지는 클래스를 다룰 때 사용한다. 선언된 상속클래스 외에 다른 클래스가 없음을 보장하므로, Enum class 처럼 when expression 을 사용할 때 불필요한 else 분기를 만들지 않아도 되어 코드를 간결하게 만들 수 있다.
interface Expr
class Num(val value: Int) : Expr
class Sum(val left:Expr, val right:Expr) : Expr
/*
sealed class를 사용하지 않을 경우 when expression 사용을 위해
불필요한 else 분기 코드를 작성해야 한다.
실제로는 else 상황이 발생하지 않음에도 코드 상에서 이를 보장하지 못하기 때문에
when is not exhaustive 라는 오류 메시지가 뜬다.
*/
fun eval(e: Expr): Int = when (e) {
is Num -> e.value
is Sum -> eval(e.left) + eval(e.right)
else -> throw IllegarArgumentException("Unknown expression")
}
sealed class Expr
class Num(val value: Int) : Expr()
class Sum(val left:Expr, val right:Expr) : Expr()
/*
sealed class를 사용할 경우 해당 파일 내에 sealed class를 상속한 클래스가
전부임을 보장하여 else 분기를 작성하지 않아도 된다.
*/
fun eval(e: Expr): Int = when (e) {
is Num -> e.value
is Sum -> eval(e.left) + eval(e.right)
}
  • 이처럼 Kotlin의 when expression은 smart casts 및 sealed class 등과 함께 사용할 경우 충분히 강력하게 사용할 수 있어서, Kotlin은 Scala 등의 언어가 제공하는 패턴 매칭 기능을 언어 차원에서 지원하지 않는다. when expression으로 충분히 커버되어 굳이 필요하지 않는 것 대비 복잡한 문법과 시스템 추가가 필요한 것에 대한 실용적 측면의 선택이었고, 실제로 추후에 도입할 수 도 있겠지만 현재로서는 굳이 도입할 필요가 없을 것으로 보인다. (한편, 커뮤니티 글들을 보면 Kotlin과 Scala를 비교할 때 Scala의 pattern matching이 Kotlin when expression에 비해 강력하다는 글이 많긴 함. — 관련 링크 : Scala vs Kotlin)

Inner modifier & inner /nested class

  • 논리적으로 클래스간에 명확한 종속 관계가 있는 상황에서 클래스 내부에 클래스를 선언한다. Java 에서는 이를 크게 내부 클래스 (Inner class, Non-static nested class)와 정적 중첩 클래스 (Static nested class, 줄여서 nested class로 씀) 로 나눈다.
  • 내부 클래스는 non-static 이므로 사용할 때마다 외부 클래스 객체를 생성해야하고, 내부 클래스 객체는 외부 클래스 reference를 저장하기 위해 메모리 점유가 발생한다. 하지만 클래스 내부에 클래스를 선언하는 경우는 일반적으로 논리적 종속 관계를 표현하기 위한 의도기 때문에, 내부에 선언한 클래스가 외부 클래스의 reference를 필요로 하지 않는 경우도 많다. 한편, 정적 중첩 클래스 (nested class)는 static 이므로 외부 클래스 reference 저장이 없어 메모리 효율성 측면에서 이점을 가진다. (관련 링크 : Java 중첩 클래스를 알아보자)
  • Java는 클래스 내부에 클래스를 선언할 때 디폴트로 내부 클래스가 되며, 중첩 클래스를 사용하기 위해서는 static 키워드를 선언해야 한다. 한편, Kotlin은 디폴트로 정적 중첩 클래스가 되며, 내부 클래스를 사용하기 위해서는 inner 제어자를 선언한 뒤, this@<외부클래스명> 을 통해 외부 클래스에 접근할 수 있다.
  • Kotlin이 클래스 내부에 클래스를 선언하는 경우에 대한 디폴트 설정을 Java와 다르게 가져간 이유는 일반적인 유스케이스에서 내부에 선언된 클래스가 외부 클래스에 대한 역참조를 필요로 하지 않는 경우가 많고, 이 경우 불필요한 메모리 누수를 줄이기 위해 정적 중첩 클래스가 낫기 때문이다. 앞선 가시성, 상속/오버라이딩 가능성 사례와 마찬가지로 보다 일반적으로 사용되며, 상대적으로 리스크가 적은 설정을 디폴트로 둔 것이다.

Class delegation

  • 인터페이스에 구현되지 않은 메소드들이 있고, 클래스에서 이 인터페이스를 프로퍼티로 사용할 경우 클래스 구현 과정에서 메소드를 구현할 필요 없이 추후 클래스 객체 생성시에 인자로 받을 인터페이스 구현체에게 메소드 구현을 위임할 수 있다. (아래 코드 참고) (관련 링크 : 제타위키 — 위임 패턴)
interface Repository{
fun getById(id: Int): Customer
fun getAll() : List<Customer>
}
interface Logger{
fun logAll()
}
class Controller(
val repository: Repository,
val logger: Logger
) : Repository, Logger {
override fun getById(id: Int): Customer = repository.getById(id)
override fun getAll(): List<Customer> = repository.getAll()
override fun logAll() = logger.logAll()
}
  • 한편 이러한 위임 패턴을 사용할 때, 위 코드 처럼 모든 위임 메소드 (delegating methods)를 직접 코딩하는 것은 번거로운 단순 작업이다. 이 경우에 대해 Kotlin은 Class delegation 을 지원하며 by 키워드를 사용하면 컴파일 과정에서 위임 메소드를 자동으로 생성해주어 코드를 간결하게 만들 수 있다. (아래 코드 참고) by 키워드는 해당 인터페이스의 구현을 해당 매개변수에 할당될 객체에게 위임함을 의미한다.
// 위 코드 블록의 Controller 클래스와 동일하게 동작하는 코드 
class Controller(
val repository: Repository,
val logger: Logger
) : Repository by repository, Logger by logger

5. Objects, object expressions & companion objects

  • Kotlin의 object 키워드를 통한 object declaration, object expression, 그리고 companion object에 대해 다룬다.

object 키워드와 object declaration

  • 싱글톤 패턴은 오직 1개의 객체만을 가지는 클래스를 사용하는 디자인 패턴이다. 데이터베이스 커넥션 풀, 로그 기록 객체 등 여러 곳에서 쓰이지만 공통된 1개의 객체로 데이터를 공유하며 사용하는 것이 효과적인 상황에서 사용한다. (관련 링크 : 싱글톤 패턴을 사용하는 이유와 문제점)
  • Java에서는 싱글톤 패턴을 사용하기 위해 관용적인 단순 코드 작성 작업이 필요하다. 한편, Kotlin은 object 키워드를 쓰면 컴파일 과정에서 싱글톤 패턴을 위한 코드를 자동으로 생성해주고, 관용적인 INSTANCE 필드 호출을 생략해주어 코드 간결성을 높일 수 있다. (아래 코드 참고)
// Java 코드에서 싱글톤 패턴 구현
public class JSingleton {
public static final JSingleton INSTANCE = new JSingleton();
private JSingleton() { }
public void foo() { System.out.println("foo"); }
}
// Java 싱글톤패턴 객체 멤버 호출 방법
JSingleton.INSTANCE.foo();

// 위와 동일하게 동작하는 Kotlin 코드
object KSingleton {
fun foo() { println("foo") }
}
// Kotlin 싱글톤 패턴 객체 멤버 호출 방법
KSingleton.foo()

object expression

  • Java에서 익명 클래스 (annonymous classes)는 인터페이스 구현체를 매개변수로 받는 메소드 호출과 같이 객체화가 한번만 필요한 클래스 구현시 사용된다. (관련 링크 : 익명클래스 사용방법)
  • Kotlin에서 구현할 인터페이스가 오직 1개의 추상 메소드 (abstract method)만 가질 경우 lambda로 구현할 수 있다. 인터페이스가 여러개의 추상 메소드를 가질 경우 object expression으로 구현할 수 있다.
  • object expression도 object : 키워드를 사용하지만 이 때 생성되는 객체는 호출시마다 매번 새롭게 생성되며 앞선 싱글톤 패턴 (object declaration)과 무관하다.

companion object

  • 싱글톤 패턴 클래스를 의미하는 object declaration을 외부클래스 내부에 사용하여 중첩 오브젝트 (nested object)를 사용할 수 있다. 한편 이 때 companion object 키워드를 사용하면, 중첩 오브젝트 멤버에 대한 접근 코드를 간소화할 수 있다. 외부클래스 내부에 싱글톤 패턴 클래스를 구현하는 목적이 일반적으로 외부클래스에 대한 공용 유틸리티성 메소드/프로퍼티를 사용하기 위한 것에 적합하다.
class A {
companion object {
fun foo() = 1
}
}
fun main(args: Arrays<String>){
A.foo()
// companion object를 사용하면 싱글톤 패턴 클래스 이름을 생략하고
// 외부 클래스에서 바로 중첩 오브젝트 멤버를 호출할 수 있다.
}
  • 한편 Java에서는 위와 같은 목적을 위해 static 메소드를 사용하며, 이를 Kotlin에서는 companion object가 대체한다. static 메소드와 비교하여 companion object의 이점은 1) 클래스이므로 인터페이스 구현 방식으로 코드를 작성할 수 있고, 2) Companion object에 대한 확장함수를 구현하여 외부클래스에 대한 정적 유틸리티성 기능을 현할 수 있다. (아래 코드 참고)
class Animal(val name:String){
companion object { }
}
// 외부 클래스에 대한 확장함수
// 외부 클래스 구현 객체에 대한 유틸리티성 기능 (ex: 외부클래스 객체 프로퍼티 print)
fun Animal.printName(){
println(this.name)
}
// 외부 클래스 내부 companion object에 대한 확장함수
// 외부 클래스에 대한 정적 유틸리성 기능 (ex : json으로 부터 외부클래스 객체 생성)
fun Animal.Companion.fromJSON(json: String) : Animal{
return Animal(convertJsonToMap(json)["name"])
}
// 아래와 같이 다른 용도의 확장함수로서 사용 가능
fun main(args: Arrays<String>){
val animal = Animal.fromJSON("... JSON 스트링 ...")
animal.printName()
}

No static keyword

  • Kotlin은 다양한 문법을 통해 기존 Java static 키워드가 필요한 기능을 구현하며, Kotlin에는 static 키워드가 제공되지 않는다.
  • Java static 메소드/멤버의 경우 1) top-level에 정의하거나, 2) object 내부 (싱글톤 패턴 클래스)에 정의하거나, 3) 클래스 내부에 companion object를 정의하고 이 내부에 정의하는 방식을 사용한다. 기본적으로는 1)로 대체 가능하며, static 메소드/멤버가 특정 클래스 내부의 private 멤버에 접근해야할 경우 2), 3)을 사용한다.
  • 이러한 static 키워드 대체의 의도는 java static 멤버의 경우 클래스 멤버이면서도 클래스 객체와 무관하게 동작하므로 의미적으로 모호하다는 문제를 개선하기 위해서였다. 클래스 내부 private 멤버에 대한 접근이 필요없다면 top-level에 정의하는 것을 지향하고, 필요할 경우 클래스 내부에서 정의하되 오직 1개의 객체를 생성하는 싱글톤 패턴의 (object, companion object) 멤버 형태로 구현한다.

@JvmStatic

  • object를 통한 싱글톤 패턴 클래스는 사실 <클래스명>.INSTANCE.<멤버> 를 Kotlin 컴파일러가 <클래스명>.<멤버> 로 간소화하여 사용할 수 있도록 하는 것이고, companion object를 통한 외부클래스 내부의 싱글톤 패턴 클래스는 사실 <외부 클래스명>.Companion.<멤버> 를 Kotlin 컴파일러가 <외부 클래스명>.<멤버>로 간소화하여 사용할 수 있도록 하는 것이다. 따라서 해당 Kotlin 코드를 Java에서 사용할 경우 Kotlin 코드와 달리 .INSTANCE , .Companion 작성이 필요하다.
  • 이는 Java 상호운용성 측면에서 혼동의 여지가 있으며, object/companion object 내부의 멤버에 대해 @JvmStatic 어노테이션을 붙이면 Java에서도 Kotlin과 동일한 코드로 사용할 수 있다.

Inner object (Inner modifier with object)

  • 외부클래스 내부 object에 대해서는 inner 제어자를 사용할 수 없다. 왜냐하면 inner 제어자는 non-static nested class를 의미하는데, object는 싱글톤 패턴 클래스로 static 이어서 상충되기 때문이다. (Modifier ‘inner’ is not applicable to ‘object’ 오류 메시지가 뜸)

6. Constants

  • 상수 (constants)로서 클래스 멤버 변수란 해당클래스에 대해 고정된 값을 가지는 속성을 의미한다. (ex: 원주율) 상수는 각 객체에 종속되지 않고 동일한 값을 가져야 하며, 한번만 초기화 되어야 하므로 Java에서는 static과 fianl을 통해 구현한다. (관련 링크 : 왜 자바에서 final 멤버 변수는 관례적으로 static을 붙일까?)
  • Kotlin 에서는 const 제어자 또는 @JvmField 어노테이션을 통해 상수를 구현한다. const 제어자를 사용하면 컴파일 과정에서 상수 변수를 해당 값으로 치환하며 (단, Primitive type 또는 String에 대해서만 동작), @JvmField 어노테이션을 사용하면 프로퍼티를 accessor를 통해서가 아니라 직접 필드에 접근할 수 있도록 하는 방식으로 mutability를 차단하여 상수를 구현한다.

7. OOP design choices

  • <OOP in Kotlin>, <Class modifiers — I, II>, <Objects, object expression & companion objects> 요약에서 다루어 생략

Conventions

1. Operator overloading

  • Kotlin의 Conventions 란 언어의 내재된 구조처럼 보이는 기능 (ex: 산술 이항/단항 연산자, 할당 연산자, 비교 연산자, in , .. , iterator, destruction declaration 등) 가 수행하는 역할을 메소드로 정의하므로써, 직접 구현한 클래스 또는 기존 클래스에 대해 이러한 기능을 오버로딩하여 사용할 수 있도록 하는 것이다. 이를 통해 빈번하게 사용되는 기능을 직관적이고 간결한 문법으로 사용할 수 있도록 한다.
  • 이 기능을 사용하기 위해서는 Convention을 제공하는 연산자 또는 기능에 대응된 메소드를 정의해주어야 하며 정의시에 operator 키워드를 붙어야 한다.
  • 산술 연산자의 경우 a + ba.plus(b) , a — ba.minus(b) , a * ba.times(b) , a / ba.div(b) , a % ba.mod(b) 로 대응된다.
  • 단한 연산자의 경우 +aa.unaryPlus() , -aa.unaryMinus() , !aa.not() , ++a/a++a.inc() , --a/a--a.dec() 로 대응된다.

2. Conventions

  • 비교 연산자의 경우 a > ba.compareTo(b)> 0, a < ba.compareTo(b)< 0,a >= ba.compareTo(b)>= 0, a =< ba.compareTo(b) <= 0,a == b. a.equals(b)로 대응된다.
  • index를 통해 원소에 접근하는 square bracket ([ ] )의 경우 x[a, b]x.get(a,b), x[a, b] = cx.set(a,b,c) 로 대응된다.
  • 해당 원소가 map/list/range 등에 속하는지 확인하는a in cc.contains(a) 로 대응된다.
  • range를 생성하는 start .. endstart.rangeTo(end) 에 대응된다.
  • For 루프를 통해 Iterator를 구현하는 for (c in a) { }a.iterator() 에 대응된다. Kotlin의 String이 Iterable 인터페이스를 구현하지 않은 Java.lang.String을 사용하면서도 이터레이트가 가능한 것은 이러한 구현 방식 때문이다.
  • For 루프와 lambda에서 사용하는 destruction declaration 문법의 경우 val (a, b) = pval a = p.component1()val b = p.component2() 대응된다. 앞서 배운 data class의 경우 이 component() 메소드를 자동으로 생성하기 때문에 destruction declaration을 사용할 수 있다. 한편, destruction declaration 문법을 사용할 때 호출하지 않는 변수는 _ 선언을 통해 사용되지 않음을 명시화할 수 있다.
  • 일반적으로 적용되지는 않지만 특정 모듈이나 알고리즘 내에서 의미를 가지는 convention의 경우 대응되는 메소드 정의시 private 또는 internal 접근제어자 선언을 통해 가시성을 제어할 수 있다.
  • Java와의 상호운용성 측면에서 Java에서도 요구하는 문법을 만족하도록 메소드를 구현하면 Kotlin에서 Convention 기능을 사용할 수 있다.

3. Equality 실습 코드 작성시 고려할 점

  • data class에서 자동으로 생성하는 equals() 함수는 Non-nullable Receiver에 대해 정의되어 있으므로 Receiver가 null인 경우를 처리하기 위한 로직을 구현해야 다. <Receiver>?.equals(value) ?: (value === null)
  • 연산자 우선순위 관련하여 elvis operator 사용시 디폴트 값 정의 부분에 괄호 치는 것을 신경써야 한다.

4. (Not) using operator overloading

  • Operator overloading은 매우 강력한 기능이지만 그에 따라 책임이 따르므로 제한된 범위 내에서 제공된다. 새로운 연산자를 개발하거나, 연산자 우선순위를 변경하는 것은 불가능하며, 허용된 연산자/Conventions에 대해 대응되는 메소드를 정의하는 방식으로 구현하도록 한다.
  • 가능한 각 연산자/Conventions에 대해 일반적으로 합의된 의미 내에서 사용해야 하며 남용하는 것은 좋지 않다.
  • Convention과 확장 함수를 통해 기존의 클래스/라이브러리에 대해서도 쉽게 기능을 확장할 수 있고, 편의성 기능과 클래스 핵심 멤버를 분리하여 코드 가독성을 높일 수 있다.

--

--