코틀린 입문 스터디 (10) Properties

mook2_y2
17 min readMar 4, 2019

--

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

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

목차

(1) Introduction

(2) From Java to Kotlin

(3) Basics

(4) Control Structures

(5) Extensions

(6) 실습 : Mastermind game

(7) Nullability

(8) Functional Programming

(9) 실습 : Mastermind in a functional style, Nice String, Taxi Park

(10) Properties

(11) Object-oriented Programming

(12) Conventions

(13) 실습 : Rationals, Board

(14) Inline functions

(15) Sequences

(16) Lambda with Receiver

(17) Types

(18) 실습 : Game 2048 & Game of Fifteen

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과 달리 에러 문구에서 정확히 어떤 변수에서 문제가 발생했는지 명시적으로 알려주므로 오류 수정시 상대적으로 용이하다는 장점이 있습니다.

--

--