In this article, I will try to summarize things from the book “Kotlin in-Depth[Vol-I]: A Comprehensive Guide to Modern Multi-Paradigm Language”. The topic of this chapter is a “class and object”.
코틀린은 자바와 같은 객체지향 언어이다. 객체지향 언어란 객체를 만들고 객체를 사용하는 프로그래밍 방법이다. 다시 말해, 프로그램을 그저 데이터와 처리방법으로 나누는게 아니라 프로그램을 다수의 “객체”를 만들고, 이들이 서로 상호작용을 통해 만들어지는 방식이다. 오늘은 객체지향 프로그래밍에 필요한 클래스, 싱글턴, 지연계산, 지연 초기화, 커스텀 세터 게터, 타입의 널 가능성 등을 다룰것이다.
programming paradigm based on the concept of “objects”, which can contain data and code: data in the form of fields, and code, in the form of procedures
- 클래스 정의와 멤버 (Class definition and members)
- 생성자 (Constructors)
- 멤버 가시성 (Member visibility)
- 내포된 클래스와 지역 클래스 (Nested and local classes)
- 널이 될 수 있는 타입 (Nullable types)
- 단순하지 않은 프로퍼티를 사용하는 방법(Using non-trivial properties)
- 객체와 동반 객체 (Objects and companions)
Defining a class
객체지향 프로그래밍의 모든것은 클래스와 객체 그리고 메서드와 속성으로 연결되어있다. 클래스 정의는 커스텀으로 정의된 연산들이 포함된 새로운 타입을 만들어준다. 기본적으로 클래스 선언은 참조타입 (reference type)을 정의한다. 이는, 인스턴스의 실제 데이터 위치를 가르키는 참조이다. 자바 인스턴스는 명시적으로 특별한 생성자 호출을 통해 생성되고, 프로그램 내에서 객체를 가리키는 모든 참조가 사라지면 가비지 컬렉터(garbage collector)에 의해 자동으로 해제된다. 코틀린도 JVM에서 돌아가기때문에 자바와 같은 garbage collector을 쓰고있다. 코틀린에서는 인라인 클래스를 사용하면 참조타입이 아닌 타입을 정의할수 있다.
garbage collector: 클래스는 reference type 이기때문에 stack 영역이 아닌 heap 영역에 할당하게 된다. 하지만 heap은 CPU에 의해 자동으로 관리 되지 않는 컴퓨터 메모리 영역이기때문에 C언어 같은 경우엔 malloc(), calloc() 을 이용해 메모리를 할당해줘야하고, free()를 이용해 사용하지 않는 메모리를 해제해줘야지만 메모리 누수가 발생하지 않는다. 하지만 자바에서는 메모리 해제를 따로 안해줘도 되는게 이를 관리해주는 garbage collector가 있기 때문이다.
클래스 내부 구조
위의 정의에 따르면 모든 Person 클래스의 인스턴스는 firstName, lastName, age 프로퍼티들과, fullName(), showMe() 라는 두 함수를 가지고 있다.
모든 프로퍼티에서는 일반적으로 위의 p 같이 변수처럼 프로퍼티를 사용할 수 있다. 이런 인스턴스를 수신 객체(receiver)라 부르고, 이는 프로퍼티에 접근할 때 사용해야하는 객체를 지정한다. 멤버함수도 동일하며 이를 메서드(method)라고 부른다.
수신객체: 확장함수가 호출되는 대상이 되는값(객체)이다. 이는 객체가 코드를 받아서 확장함수를 실행하기 때문에 수신객체이다.
클래스 내부에서는 위와 같이 this 식으로 수신 객체를 참조할 수 있고 대부분의 경우 this 를 디폴트로 가정하기 때문에 생략해도 괜찮다. 하지만 setName() 과 같이 프로퍼티와 파라미터 이름이 같은경우 이를 구분해주기위해 this를 꼭 사용해야한다.
프로퍼티가 사용하는 내부 필드는 항상 캡슐화돼 있고, 클래스 밖에서는 이 내부 필드에 접근 할 수 없다. 그래서 클래스 인스턴스를 생성해서 사용할수 있다.
val person = Person()
//create a person instance
생성자 호출을 사용하면 프로그램이 새 인스턴스에 대한 힙 메모리를 할당 한 다음, 인스턴스의 상태를 초기화해주는 생성자 코드를 호출한다. person
은 아무 인자도 받지 않는 디폴트 생성자이다(빈생성자).
코틀린 클래스는 공개(public)가시성이다. 즉 코드 어느 부분에서나 클래스를 사용할 수 있다. 최상의 함수와 마찬가지로
internal
또는private
으로 설정해 사용제한을 줄수 있다.
생성자
생성자(constructor)는 클래스 인스턴스를 초기화해주고 인스턴스 생성 시 호출되는 특별한 함수다.
Person의 파라미터들은 프로그램이 클래스의 인스턴스를 생성할 때 클래스에 전달된다. 이 파라미터들을 통해 프로퍼티를 초기화할 수 있다. 이를 주생성자 (primary constructor)이라고 한다. 하지만 주생성자는 함수와 다르게 한개의 바디 (block code){ }만을 가지고 있지 않다. property initializers 과 initialization blocks
포함되어 있다.
initialization blocks 은 init
키워드가 앞에 붙으며 클래스가 생성 될 때마다 불린다. 이는 한 클래스 안에 init {} 이 여럿 들어갈 수 있다. return
이 들어갈 수 없다.
주생성자 파라미터를 프로퍼티 초기화나 init 블록 밖에서 사용 할 수 없다. 예를들어 위의 코드에 println(firstName)
한줄이 포함 되 어있으면 잘못된 코드이다. 이를 해결하기 위해선 생성자 파라미터의 값을 저장할 멤버 프로퍼티를 정의 하는것이다.
val firstName = firstName
val lastName = lastName
또는 코틀린에서 제공해주는 val
, var
키워드를 덧붙이면 생성자 파라미터의 값을 멤버 프로퍼티로 만들 수 있다.
만약 코틀린에서 여러 생성자를 사용해 클래스 인스턴스를 서로 다른 방법으로 초기화하고싶다면? 부생성자(secondary constructor)를 사용하면 된다.
부생성자는 기본적으로 Unit타입 값을 반환하는 함수와 마찬가지 형태이기때문에 return 을 사용할 수 없다. 클래스에 주생성자를 선언하지 않은 경우, 모든 부 생성자는 자신의 본문을 실행하기 전에 프로퍼티 초기화와 init() 블락을 먼저 실행한다. 이때 클래스에 주생성자가 있다면, 모든 부생성자는 주생성자에게 위임을 하거나 다른 부생성자에게 위임을 해야한다. 부생성자의 파라미터는 val/var을 사용할 없다.
멤버 가시성
visibility는 클래스 멤버마다 다르게 지정 할 수있다. 즉, 각각 어떤 영역에서 쓰일 수 있는지 결정 할 수 있다.
- public (공개) : default이고 멤버를 어디서나 볼수있다.
- internal (모듈 내부) : 멤버가 속한 클래스가 포함된 컴파일 모듈 내부에서만 볼 수 있다.
- protected (보호) : 멤버가 속한 클래스와 모든 하위 클래스에서 볼 수 있다.
- private (비공개) : 멤버가 속한 클래스 내부에서만 볼 수 있다.
주생성자의 가시성을 지정하려면 constructor
키워드를 명시해야한다.
내포된 클래스
클래스는 다른 클래스도 멤버로 가질수 있다. 이를 nested class 내포된 클래스라고 한다.
- 내포된클래스 밖에서 부를때 바깥쪽 클래스 이름을 붙여야한다.
Person.Id
- 자바와 다르게 바깥 클래스는 내포된 클래스의 비공개 멤버에 접근 할수 없다.
- 바깥 클래스의 현재 인스턴스에 접근하려면 내포된 클래스 앞에
inner
키워드를 붙여줘야한다.
자바와 다른점은 자바는 외부 클래스와 연관이 없을 경우엔 static 키워드를 붙여주지만 코틀린은 inner 가 없으면 연관이 없고 inner 를 붙여줘야지만 연관성이 생긴다.
지역 클래스
함수 본문에서 클래스를 정의할 수 있다. 지역클래스(Local classes)는 자신을 둘러 싼 코드 블록 안에서만 쓰일 수 있다.
- 지역 클래스에서 클래스 안의 값을 포획(capture) 또는 변경할 수 있다. (자바는 변경은 불가능)
- 내포된 클래스와 다르게 가시성 변경자를 붙일 수 없다.
- 항상 자신을 둘러싼 블록의 영역으로만 제한된다.
- 클래스와 동일하게 클래스가 포함할수있는 모든 멤버를 포함할수있다. 다만 내포된 클래스는 반드시 inner 클래서여만한다.
- inner를 붙이는 이유는 inner 키워드가 안붙은 클래스를 포함할경우 혼돈을 줄 수 있다. 내포된클래스는 다른 외부에서 접근이 가능하지만 지역클래스 안에 있고 접근이 가능하다면 구문 영역에 따른 가기성 규칙에 어긋나기 때문이다.
그럼 언제 nested class 를 쓰고, local class 를 쓰고 할까?
널 가능성
Nullability는 아무것도 참조하지 않는 경우를 나타내는 특별한 값이다. 코틀린에서는 널 값이 될 수 있는 참조 타입과 널 값이 될 수 없는 참조타입을 확실히 구분해준다. 이를 통해서 NullPointerException 예외를 상당 부문 막을 수 있다.
널이 될 수 있는 타입
자바에서는 모든 참조타입이 널이 될 수 있지만 코틀린에서는 “?” 를 붙여서 타입을 널이 될 수 있는 타입으로 지정해야한다.
fun isBooleanString(s: String?) = s == ”false” || s == “true”
- ? 이 붙은 값엔 null 값과 null이 아닌 값이 대입될수있다. 하지만 반대는 불가능하다.
- 가장 작은 널이 될 수 있는 타입은 Nothing? 이다. 널 상수 이외의 어떤값도 포함하지 않는 최하의 타입이다.
- 가장 큰 널이 될 수 있는 타입은 가장 큰 타입인 Any?이다.
널 가능성과 스마트 캐스트
널이 될 수 있는 값을 처리하는 가장 직접적인 방법은 해당 값을 조건문을 사용해 null과 비교하는 방법이다.
타입을 바꾸지 않고 null 에 대한 검사를 추가하면 컴파일러는 널값인지 아닌지에 대한 사실을 알 수 있다. 그 후 컴파일러는 값 타입을 세분화함으로써 널이 될 수 있는값을 널이 될수 없는 값으로 타입 변환을 한다. 이를 스마트캐스트
라고 한다.
- 스마트캐스트는 when이나 loop같은 조건 검사가 들어가는 다른 문이나 식에서도 작동한다.
널 아님 단언 연산자 (Not null assertion) “!!”
은 !!로 표현하고 원래 타입의 널이 될 수 없는 버전이다. 이는 NullPointException을 발생시킬수 있는 연산자다. 일반적으로 널이 될 수 있는 값을 사용하려면 그냥 예외를 던지는 방식보다 더 타당한 응답을 제공해야하기때문에 !! 를 사용하는건 권장하지 않는다.
val n = readLine()!!.toInt()
하지만 !! 사용을 정당화할 수 있는 경우가 아래와 같이 있긴하다.
이는 initialize() 함수를 불러 name의 값을 초기화 해주기 때문에 sayHello()함수에서는 name의 값이 무조건적으로 있다는걸 알기때문이다. 하지만 스마트 캐스트를 적용할 수 있게 하는 편이 낫다(널 확인).
안전한 호출 연산자 “?”
안전한 호출을 사용하면 불필요한 if 식과 임시 변수의 사용을 줄여서 코드를 단순화 할수 있다. 이는 안전한 호출 연산자 “?” 를 연쇄시켜 사용하는것이다.
readLine()?.toInt()?.toString(16)
안전한 호출 연산자가 널을 반환할수 있기 때문에 호출하는 쪽에서도 타입 변화를 염두해 둬야한다.
val n = readInt()
//Int?
엘비스 연산자
널이 될 수 있는 값을 다룰 때 유용한 연산자로 널 복합 연산자(null coalescing operator)인 ?: 를 쓸 수 있다. 이 연산자를 사용하면 널값을 대신할 디폴트 값을 지정할 수 있다.
val name: String?
println(“${name ?: “Hello”}”)
// == if (name != null) { … }
name 에 널값이 있을경우 “Hello”를 출력하게 된다. return 이나 throw 같은 제어 흐름을 깨는 코드를 엘비스 연산자를 오른쪽에 넣어서 if 식을 대신 할 수도 있다.
단순한 변수 이상인 프로퍼티
코틀린의 프로퍼티는 일반 변수를 넘어서, 프로퍼티 값을 읽거나 쓰는 법을 제어할 수 있는 훨씬 더 다양한 기능을 제공한다.
최상의 프로퍼티 (Top-level properties)
클래스나 함수와 마찬가지로 최상위 수준에 프로퍼티를 정의 할 수 있다. 이런경우 프로퍼티는 전역 변수나 상수와 비슷한 역활을 한다.
- 프로퍼티에 최상의 가시성(public/internal/private)을 지정할 수 있다.
- 임포트 디렉티브에서 최상위 프로퍼티를 임포트 할 수 있다.
늦은 초기화 (Late initialization)
코틀린은 변수선언을 먼저하고 초기화는 뒤로 미루는 기능들을 제공한다. 초기화를 늦추면 좋은점이 사용할지 모르는 데이터를 미리 초기화할 필요가 없어서 성능 향상에 도움이 된다.
lateinit
을 붙여서 변수를 선언하며 이 표시가 붙은 프로퍼티는 값을 읽으려고 시도할 때 프로퍼티가 초기화 됐는지 검사해서 초기화되지 않은 경우 UninitializedPropertyAccessException을 던지는것을 제외하면 일반 프로퍼티와 같다.
lateinit var text: String
- 가변 프로퍼티 (var)로 정의해야한다
- 널이 아닌 타입이여야하고 Int나 Boolean 같은 원시 값을 표현하는 타입이 아니여야한다.
- lateinit 프로퍼티를 정의하면서 초기화 식을 지정해 값을 바로 대입할수 없다.
커스텀 접근자 사용하기
커스텀 접근자는 프로퍼티 값을 읽거나 쓸 때 호출 되는 특별한 함수이다.
만약, 아래와 같은 코드를 실행한다면,
val person = Person(“a”,”b”)
프로그램이 자동으로 get()
을 호출하고 get()에는 파라미터가 없다. 반면 반환 타입은 프로퍼티 타입과 같아야한다.
println(person.fullName) //a b
- 위의 코드처럼 fullName이란 변수를 부를때마다 get 은 항상 호출된다.
- 뒷받침하는 필드 (backing field)가 없기 댸문에 클래스 인스턴스에서 전혀 메모리를 차지하지 않는다.
- 위의 변수/객체를 호출 할때마다 로그를 남기거나 데이터 저장을 한다는등의 접근을 해야할 때 사용하면 좋다.
- 함수가 없는 파라미터처럼 동작하므로, 계산 하는 과정에서 예외가 발생하지 않거나, 값을 계산 하는 비용이 싸거나, 값을 캐시해 두거나, 클래스 인스턴스 상태가 바뀌기 전 여러번 프로퍼티를 읽거나 함수를 호출해도 항상 같은 결과값을 내는 경우에는 함수보다 프로퍼티를 사용하는게 좋다.
var로 정의하는 가변 프로퍼티에는 값을 읽기 위한 getter와 값을 설정하기 위한 setter라는 두가지 접근자가 있다.
세터의 파라미터는 단 하나이며, 타입은 프로퍼티 자체의 타입과 같아야한다. 보통은 파라미터의 타입을 항상 미리 알 수 있기 때문에 세터에서는 파라미터 타입을 생략한다.
지연 계산 프로퍼티 위임 (Lazy properties and delegates)
프로퍼티를 처음 읽을때까지 그 값에 대한 계산을 미뤄두고 싶을때 lazy 프로퍼티를 사용한다.
main() 함수에서 사용자가 적절한 명령으로 프로퍼티 값을 읽기 전까지, 프로그램은 lazy 프로퍼티 값을 계산하지 않는다.
val text: String by lazy { File(“data.txt”).readText() }
위의 구문은 delegate object를 통해 프로퍼티를 구현하게 해주는 위임 프로퍼티(delegate property)라는 기능의 특별한 경우이다. 위임 객체는 by 키워드 다음에 위치하며 코틀린이 정한 규약을 만족하는 객체를 반활할 수 있는 임의의 식이 될 수 있다.
- lateinit 프로퍼티와 달리 lazy 프로퍼티는 불변 프로퍼티다. lazy 프로퍼티는 초기화된 다음에는 변경되지 않는다.
- lazy 프로퍼티는 스레드 안전(thread safe)하다. 다중 스레드 환경에서도 값을 한 스레드 안에서만 계산하기 때문에 lazy 프로퍼티에 접근하려는 모든 스레드는 궁극적으로 같은 값을 얻게 된다.
객체
코틀린에서 객체 선언은 클래스와 상수를 합한 것이며 객체 선언을 통한 싱글턴 클래스를 만들수 있다.
싱글턴? 인스턴스가 단 하나만 존재하는 클래스
객체선언
어떤 클래스에 인스턴스가 오직 하나만 존재하게 보장하는 싱글턴 패턴을 내장하고 있다. 선언 할때 object 키워드를 사용한다.
위와 같은 객체 선언은 클래스를 정의하는 동시에 클래스의 인스턴스를 정의하는 것이기도 하다. 일반적으로 객체의 인스턴스는 단 하나뿐이므로 인스턴스만 가리켜도 어떤 타입을 쓰는지 충분히 알 수 있다.
- 객체 정의는 스레드 안전하다. 컴파일러는 실행되는 여러 스레드에서 싱글턴에 접근하더라도 오직 한 인스턴스만 공유되고 초기화 코드도 단 한번만 실행되도록 보장된다.