개발/코틀린

(코틀린) 클래스

DinoDev 2022. 10. 3. 21:07
728x90
반응형

클래스 정의하기

클래스 내부 구조

코틀린에서 클래스는 class 키워드와 클래스이름으로 정의합니다.

클래스는 내부에서 변수와 함수를 가질 수 있습니다.

class Person {
    var firstName: String = ""
    var familyName: String = ""
    var age: Int = 0

    fun fullName() = "$firstName $familyName"

    fun showMe() {
        println("${fullName()}: $age")
    }
}

Person 클래스이고 firstName, familyName, age라는 프로퍼티가 있고 fullName()과 showMe()라는 함수가 있습니다.

 

클래스 내부에서 본인 자체에 접근할 때는 this라는 키워드를 사용하고 이를 수신객체라고 말합니다.

보통은 수신객체를 생략해서 사용합니다.

fun fullName() = "${this.firstName} ${this.familyName}"

fun showMe() {
    println("${this.fullName()}: $age")
}

하지만 아래와 같이 파라미터의 이름과 클래스 프로퍼티의 이름이 같은 경우에 this를 사용합니다.

class Person {
    var firstName: String = ""
    var familyName: String = ""

    fun setName(firstName: String, familyName: String) {
        this.firstName = firstName
        this.familyName = familyName
    }
}

생성자

위 예제에서는 생성자를 지정하지 않아서 객체를 생성하면 기본 생성자를 사용해서 객체를 만들게 됩니다.

class Person(firstName: String, familyName: String) {
    val fullName = "$firstName $familyName"
}

fun main() {
    val person = Person("석준", "정")
    println(person.fullName) // 석준 정
}

클래스 이름 뒤에 만드는 생성자를 주생성자라고 부릅니다.

주생성자는 block이 없어서 생성과 동시에 어떤 작업을 할 수 없습니다. 그래서 init { }을 사용해서 초기화 시점에 작업을 진행 할 수 있습니다.

class Person(firstName: String, familyName: String) {
    val fullName = "$firstName $familyName"

    init {
        println("Created new Person instance: $fullName")
    }
}

생성자 파라미터 자체를 클래스 프로퍼티로 사용하고 싶다면 val/var를 붙이면 됩니다.

class Person(val firstName: String, val familyName: String) {
    val fullName = "$firstName $familyName"
}

함수와 마찬가지로 디폴트값을 사용할 수 있고 vararg도 사용 가능합니다.

class Person(val firstName: String, val familyName: String = "") {
    fun fullName() = "$firstName $familyName"
}

class Room(vararg val persons: Person) {
    fun showNames() {
        for (person in persons) {
            println(person.fullName())
        }
    }
}

fun main() {
    val room = Room(Person("석준"), Person("디노", "정"))
    room.showNames()
}

constructor 키워드를 사용하면 부생성자(secondary constructor)를 만들 수 있는데 부생성자는 여러개를 만들 수 있습니다.

class Person {
    val firstName: String
    val familyName: String

    constructor(firstName: String, familyName: String) {
        this.firstName = firstName
        this.familyName = familyName
    }

    constructor(fullName: String) {
        val names = fullName.split(" ")
        if (names.size != 2) {
            throw IllegalArgumentException("Invalid name: $fullName")
        }
        firstName = names[0]
        familyName = names[1]
    }

}

: 키워드를 사용해서 부생성자에서 부생성자를 호출 할 수도 있고, 부생성자에서 주생성자를 호출 할 수 있습니다.

만약 주생성자가 있다면 부생성자는 주생성자를 꼭 호출 해야만 합니다.

class Person {
    val fullName: String

    constructor(firstName: String, familyName: String) : this("$firstName $familyName")

    constructor(fullName: String) {
        this.fullName = fullName
    }
}

class Person(val fullName: String) {
    constructor(firstName: String, familyName: String) : this("$firstName $familyName")
}

내포된 클래스(Nested Class)

클래스는 함수, 프로퍼티, 생성자 외에 내부에 클래스를 가질 수 있습니다.

class Person(val id: Id, val age: Int) {
    class Id(val firstName: String, val familyName: String)

    fun showMe() {
        println("${id.firstName} ${id.familyName}, $age")
    }
}

fun main() {
    val id = Person.Id("석준", "정")
    val person = Person(id, 31)
    person.showMe() // 석준 정, 31
}

inner 키워드를 사용하면 자신을 둘러싼 외부 클래스의 현재 인스턴스에 접근할 수 있습니다.

class Person(val firstName: String, val familyName: String) {
    inner class Possession(val description: String) {
        fun showOwner() = println(fullName()) // Person 클래스의 fullName() 함수 접근
    }

    private fun fullName() = "$firstName $familyName"
}

fun main() {
    val person = Person("석준", "정")
    val wallet = person.Possession("Wallet")
    wallet.showOwner() // 석준 정
}

this 뒤에 @ 를 사용해서 상위 스코프의 인스턴스에 접근 할 수 있습니다.

class Person(val firstName: String, val familyName: String) {
    inner class Possession(val description: String) {
        fun getOwner() = this@Person
    }
}

지역 클래스

지역클래스는 코드 블럭 안에서만 사용할 수 있는 클래스입니다.

fun main() {
    class Point(val x: Int, val y: Int) {
        fun shift(dx: Int, dy: Int): Point = Point(x + dx, y + dy)
        override fun toString() = "($x, $y)"
    }

    val p = Point(10, 10)
    println(p.shift(-1, 3)) // (9, 13)
}

지역함수와 동일하게 지역클래스도 코드블럭에 값을 포획(capture) 할 수 있고 변경할 수도 있습니다.

fun main() {
    var x = 1

    class Counter {
        fun increment() {
            x++
        }
    }

    Counter().increment()
    println(x) // 2
}

널 가능성

코틀린에서 참조 값에는 아무것도 참조하지 않는 경우를 나타내는 null 이라는 값이 있습니다.

null인 참조에서 어떤 프로퍼티나 어떤 함수에 접근하면 NPE(Null Pointer Exception)이 발생하고 이것 은 런타임 오류라고 합니다.

코틀린 타입에서는 null을 참조할 수 있는 타입과 없는 타입을 구분해서 사용하기 때문에 NPE 발생를 많이 방지할 수 있습니다.

널이 될 수 있는 타입

기본적으로 모든 타입은 널을 참조 할 수 없는 타입입니다. 널을 참조 할 수 없는 타입에 null 값을 넣게 되면 컴파일 에러가 발생합니다.

fun isLetterString(s: String): Boolean {
    if (s.isEmpty()) return false

    for (ch in s) {
        if (!ch.isLetter()) return false
    }
    
    return true
}

fun main() {
    println(isLetterString("abc"))
    println(isLetterString(null)) // Null can not be a value of a non-null type String
}

널을 참조하게 만들려면 타입 뒤에 ? 키워드를 사용합니다.

?가 붙은 타입이 ?가 붙지 않은 타입보다 상위 타입이라서 nullable한 타입을 non-null 타입에 대입할 수 없습니다.

fun isBooleanString(s: String?) = s == "false" || s == "true"

fun main() {
    println(isBooleanString(null))
    
    val s: String? = "abc"
    /**
    Type mismatch.
    Required: String
    Found: String?
     */
    val ss: String = s 
}

널 가능성과 스마트 캐스트

널이 될 수 있는 값을 처리하는 가장 직접적인 방법은 조건물을 사용해 null과 비교하면 됩니다.

fun isLetterString(s: String?): Boolean {
    if (s == null) return false

    if (s.isEmpty()) return false

    for (ch in s) {
        if (!ch.isLetter()) return false
    }
    
    return true
}

함수의 파라미터는 val로 불변이기 때문에 null검사를 마치고 난 후에 파라미터는 non-null로 인지 됩니다.

var인 값은 변경 될 수 있기 때문에 null 검사를 하더라도 스마트 캐스트가 되지 않아 nullable 타입으로 인지 될 수 있습니다.

fun main() {
    var s: String? = readLine()
    if (s != null) {
        s = readLine()
        println(s.length) // Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
    }
}

널 아님 단언 연산자

nullable한 타입에서 개발자는 타입이 null이 아님을 단언하도록 하는 것이 !! 연산자 입니다.

이 연산자는 실제로 값이 null일 때 NPE를 발생 시켜서 프로그램이 강제종료 될 수 있으니 조심히 사용해야 합니다.

실제로는 사용하지 않는 것이 좋습니다.
fun main() {
    var name: String? = null

    fun initialize() {
        name = "석준"
    }

    fun sayHello() {
        println(name!!.uppercase())
    }

    initialize() // 이 코드가 sayHello()보다 먼저 호출 하지 않으면 NPE 발생 
    sayHello()
}

안전한 호출 연산자

nullable한 타입에서 null이 아닐 때 호출 하도록 하는 safe call하는 방식이 있습니다.

safe call은 null이 아니면 동작을 수행하고 null이면 null을 리턴합니다.

fun main() {
    val n = readLine()?.toInt()

    if (n != null) {
        println(n + 1)
    } else {
        println("No value")
    }
}

엘비스 연산자

널을 다루는 유용한 연산자로 ?: 가 있습니다.

fun sayHello(name: String?) {
    println("Hello, ${name ?: "Unknown"}")
}

fun main() {
    sayHello("석준") // Hello, 석준 
    sayHello(null) // Hello, Unknown
}

엘비스 연산자는 기본값을 지정할 때 유용하고 특정 값이 null일 때 함수를 종료 할 수도 있습니다.

fun main() {
    val n = readLine()?.toInt() ?: 0
    println("입력한 숫자는 ${n}입니다.")
}

fun showName(firstName: String?, familyName: String?) {
    firstName ?: return
    familyName ?: return
    
    println("$firstName $familyName")
}

단순한 변수 이상인 프로퍼티

최상위 프로퍼티

클래스나 함수와 마찬가지로 최상위 수준에 프로퍼티를 정의할 수 있습니다.

val prefix = "Hello, "

fun main() {
    val name = readLine() ?: return
    println("$prefix$name")
}

늦은 초기화

클래스를 인스턴스로 만들 때 프로퍼티를 무조건 초기화 해야만 합니다.

하지만 초기화 되는 것이 특정 시점 이후여야만 하는 경우가 생기는데 이 때 늦은 초기화를 합니다.

늦은 초기화는 lateinit var로 프로퍼티를 선언합니다.

class Person {
    lateinit var firstName: String
    lateinit var familyName: String

    fun parseName(fullName: String) {
        val names = fullName.split(",")
        this.firstName = names[0]
        this.familyName = names[1]
    }

    fun showMe() {
        println("$firstName $familyName")
    }
}

fun main() {
    val person = Person()
    person.showMe()
}

초기화 하지 않고 사용하면 UninitializedPropertyAccessException 에러가 발생합니다.

초기화여부를 확인하기 위해서 isInitialized를 사용합니다.

fun showMe() {
    if (::firstName.isInitialized && ::familyName.isInitialized) {
        println("$firstName $familyName")
    }
}

커스텀 접근자

지금까지 클래스 내부에 프로퍼티는 단순 값을 저장하기 위한 목적으로 사용했습니다.

하지만 값을 저장할 때 특별한 로직이 들어가거나 값을 가져올 때 특별한 로직이 들어가게 할 수 있습니다.

커스텀 접근자 블럭에서 실제 프로퍼티에 접근하기 위해서는 field라는 값을 사용합니다.

class Person(var firstName: String, var familyName: String) {
    var fullName: String
        get() = "$firstName $familyName"
        set(value) {
            val names = value.split(" ")
            this.firstName = names[0]
            this.familyName = names[1]
        }

    var age: Int = -1
        set(value) {
            if (value <= 0) {
                throw IllegalArgumentException("Invalid age: $value")
            }
            field = value
        }
}

getter는 외부로 노출시키면서 내부에서는 setter만 노출하고 싶을 때는 커스텀 접근자에 가시성을 추가하면 됩니다.

import java.util.*

class Person(name: String) {
    var lastChanged: Date? = null
        private set
    
    var name: String = name
        set(value) {
            lastChanged = Date()
            field = value
        }
}

지연 계산 프로퍼티와 위임

lateinit은 개발자가 특정 시점에 초기화를 해줘야만 에러가 발생하지 않습니다.

지연 계산 프로퍼티인 lazy를 사용하면 특정 시점에 초기화 하는게 아닌 프로퍼티에 접근할 때 초기화가 됩니다.

lazy를 사용하기 위해서는 by라는 키워드를 사용해야 합니다.

val name by lazy {
    println("name프로퍼티 lazy로 초기화")
    "석준 정"
}

fun main() {
    /**
    1
    name프로퍼티 lazy로 초기화
    석준 정
    2
    석준 정
     */
    println("1")
    println(name)
    println("2")
    println(name)
}

name은 1에서 초기화가 되고 2에서는 기존의 값을 그대로 사용합니다.


object

인스턴스가 하나만 존재하는 클래스를 object로 만들 수 있습니다.

object 선언

object Application {
    val name = "My Application"

    override fun toString(): String = name

    fun exit() {}
}

Application라는 object는 프로그램에서 하나뿐인 인스턴스를 가지고 초기화는 object에 접근할 때 됩니다.

companion object

class 내부에 object를 만들어서 인스턴스를 여러개 만들 수도 있고 하나만 존재하도록 할 수 있습니다.

companion object는 class 내부에 한개만 존재할 수 있습니다.

class Application(val name: String) {
    fun showMe() {
        println(String.format(introductionTemplate, name))
    }

    companion object {
        private const val introductionTemplate = "안녕하세요 이 어플리케이션은 %s 입니다."
    }
}

fun main() {
    Application("코틀린 완벽 가이드").showMe() // 안녕하세요 이 어플리케이션은 코틀린 완벽 가이드 입니다.

}

object expression

명시적인 선언 없이 객체를 만들 수 있는 object expression을 제공합니다.

fun main() {
    fun midPoint(xRange: IntRange, yRange: IntRange) = object {
        val x = (xRange.first + xRange.last) / 2
        val y = (yRange.first + yRange.last) / 2
    }

    val midPoint = midPoint(1..5, 2..6) // val midPoint: <anonymous object : Any>
    println("${midPoint.x} ${midPoint.y}")
}

하지만 object expression이 최상위 레벨에 있게 되면 Any 타입으로 되기 때문에 위 예제처럼 x나 y는 접근할 수 없습니다.

fun midPoint(xRange: IntRange, yRange: IntRange) = object {
    val x = (xRange.first + xRange.last) / 2
    val y = (yRange.first + yRange.last) / 2
}

fun main() {

    val midPoint = midPoint(1..5, 2..6) // val midPoint: Any
    println("${midPoint.x} ${midPoint.y}") // Any라서 x와 y에 접근할 수 없음
}
728x90
반응형

'개발 > 코틀린' 카테고리의 다른 글

(코틀린) 특별한 클래스 사용하기  (0) 2022.10.23
(코틀린) 함수형 프로그래밍  (0) 2022.10.10
(코틀린) 예외처리  (0) 2022.09.25
(코틀린) 조건문, 반복문  (0) 2022.09.25
(코틀린) 함수  (0) 2022.09.24