Kotlin

Kotlin 문법 - 초보편

팅리엔 2022. 9. 29. 00:56

헷갈릴만한 코틀린 문법을 정리해본다.

 

이전 글)

Kotlin 문법 - 입문편

 

 


 

 

객체 Object

val person = object {
    val name: String = "홍길동"
    val age: Int = 30
}

println(person.name) //홍길동

name, age처럼 객체에 포함된 변수들을 property라고 한다.
프로퍼티는 반드시 선언과 동시에 초기화되어야 한다.

프로퍼티가 자바의 필드와 동일해보이지만, 프로퍼티는 필드와 getter, setter가 합쳐진 개념이다.

 

 

클래스 Class

class Person {
    var name: String = ""
    var age: Int = 0
}

fun main(args: Array<String>) {
    var person = Person()
    person.name = "홍길동"
    person.age = 30
}

클래스 이름이 파일 이름과 같지 않아도 되고, 한 파일 내에 여러개의 public 클래스를 선언할 수 있다. (일반적이지 않음)

기본 접근지정값은 public이다. (자바는 default)

 

 

메모리 힙 Heap 영역의 존재 이유

메모리의 스택 Stack 영역에는 로컬 변수가 저장되고, 힙 Heap 영역에는 객체가 저장된다.

함수의 실행이 끝나면 로컬 변수는 스택 영역에서 지워지지만 객체는 힙 영역에서 지워지지 않는다.

힙 영역을 두면, 하나의 객체를 여러 참조 변수에서 공유하는 형태로 사용할 수 있어 메모리 공간을 훨씬 절약할 수 있다.

 

 

문자열간 + 연산

fun main(args: Array<String>) {
    var first = "Hello"
    var second = "World"
    first += second
    println(first) //Hello World
}

코틀린에서 Byte, Short, Int, Long, Float, Double, Char, Boolean 타입을 제외한 타입은 모두 참조 타입이다.

문자열끼리 + 연산을 하면 원래 갖고 있던 문자열에 새로운 문자열이 덧붙여지는 게 아니라, 기존의 문자열은 그대로 남고 합쳐진 문자열이 새로 생성된다.

위 코드에서 first가 가리키고 있던 "Hello"가 힙 영역에 그대로 있는 채 "Hello World" 문자열이 새로 생성되는 것이다.

 

이렇게 + 연산을 계속 반복한다면 언젠가는 메모리 공간이 부족해질 수도 있다.

하지만 미아가 된 객체는 메모리 공간이 부족해질 때 가비지 컬렉션(Garbage Collection) 기능에 의해 소멸된다.

가비지 컬렉션이 일어날 때는 삭제할 미아 객체들을 탐색해야 하기 때문에 순간적으로 프로그램이 멈추는 현상(프리징)이 일어날 수 있다. 

 

 

===, !== 연산자

=== 연산자는 두 참조 변수가 같은 객체를 가리키는지 여부를 조사한다. !== 는 그 반대.

fun main(args: Array<String>) {
    var a = "one"
    var b = "one"
    println(a === b) //true
    println(a == b) //true
    
    b = "on"
    b += "e"
    println(a === b) //false
    println(a == b) //true
    
    b = a
    println(a === b) //true
    println(a == b) //true
}

String 타입의 리터럴로 이루어진 표현식에 한해, 참조 변수에 동일한 문자열을 저장하면 컴파일러는 매번 새로운 문자열을 생성하지 않고 하나의 문자열을 가리키도록 만든다.

 

그러나 표현식이 리터럴로만 이루어져 있지 않으면, b가 a와 똑같은 문자열을 저장하고 있다고 하더라도 새로운 문자열이 만들어진다.

 

코틀린의 === 연산자는 자바의 == 연산자와 같다.

코틀린의 == 연산자는 자바의 equals 메서드와 같다.

 

 

생성자 Constructor 와 초기화 Initializer 블록

class Person constructor(name: String, age: Int) { //constructor 키워드 생략 가능
    val name: String = name
    val age: Int
    
    init {
        this.age = age
    }
}

fun main(args: Array<String>) {
    val person = Person("홍길동", 30)
    println("이름: ${person.name}") //홍길동
    println("나이: ${person.age}") //30
}

인스턴스가 생성되면, 위에서부터 순서대로 프로퍼티의 선언 및 초기화문, init 블록이 실행된다.

생성자의 매개변수는 init 블록 내부와 프로퍼티 선언시 초기화 하는 데 사용할 수 있다.

init 블록은 여러 개로 나누어 쓸 수 있다.

 

class Person(val name: String, var age: Int = 20)

fun main(args: Array<String>) {
    val person = Person("홍길동")
    println("이름: ${person.name}") //홍길동
    println("나이: ${person.age}") //20
}

생성자 매개변수 앞에 var, val 키워드를 붙이면 동일한 이름의 프로퍼티가 같이 선언된다.

그리고 생성자 매개변수에 들어온 인수가 프로퍼티의 초기값이 된다.

 

 

보조 생성자 Secondary Constructor

class Time(val second: Int) {
    constructor(minute: Int, second: Int) : this(minute * 60 + second)
    constructor(hour: Int, minute: Int, second: Int) : this(hour * 60 + minute, second)
}

fun main(args: Array<String>) {
    println("${Time(5, 30).second}초") //330
    println("${Time(1, 5, 30).second}초") //3930
}

 

 

프로퍼티와 Getter, Setter

프로퍼티에는 변수뿐만 아니라 Getter/Setter도 포함되어 있다.

class Person {
    var age: Int = 0
        get() {
            return field
        }
        set(value) {
            field = if(value > 0) value else 0
        }
}

fiels는 실제로 값이 저장되는 프로퍼티 속 변수를 나타내는 특수 식별자이다.

val 프로퍼티는 초기값이 주어지면 더 이상 값을 변경할 수 없다. 그래서 Getter만 존재한다.

디폴트 Getter/Setter의 동작을 커스터마이징 하려면 위처럼 별도로 정의해주어야 한다.

 

class Person {
    var age: Int = 0
        get
        set
        // 디폴트 Getter/Setter가 만들어지는데 별도 정의한 것은 접근 지정자 설명 참조
        
    var name = ""
    	get() = "이름: $field"
        
    val isYoung
    	get() = age < 30
}

Getter 속 문장이 하나일 때는 축약이 가능하다.

Getter의 반환값이 field가 아니라면 isYoung처럼 프로퍼티의 타입을 생략할 수 있다. Getter 반환 타입으로 타입 추론이 가능하기 때문이다.

 

 

연산자 오버로딩 Operator Overloading

class Point(var x = 0, var y = 0)

fun main(args: Array<String>) {
    val p1 = Point(2, 3)
    val p2 = Point(15, 22)
    val p3 = p1 + p2
}

위 코드에서 Point 타입에 더하기 연산을 사용하고 있다. 이는 불가능하다. 

하지만 아래처럼 연산자 오버로딩을 이용하면 가능해진다.

class Point(var x: Int = 0, var y: Int = 0) {
    operator fun plus(other: Point) : Point {
        return Point(x + other.x, y + other.y)
    }
    
    operator fun minus(other: Point) : Point {
        return Point(x - other.x, y - other.y)
    }
    
    operator fun times(other: Point) : Point {
        return Point(x * other.x, y * other.y)
    }
    
    operator fun div(other: Point) : Point {
        return Point(x / other.x, y / other.y)
    }
}

 

오버로딩이 가능한 연산자는 다음 표와 같다.

표현식 함수 이름 컴파일 시 실제로 적용되는 형태 매개변수 타입 함수 반환 타입
+a unaryPlus a.unaryPlus() - 자유
-a unaryMinus a.unaryMinus() - 자유
!a not a.not() - 자유
a + b plus a.plus(b) 자유 자유
a - b minus a.minus(b) 자유 자유
a * b times a.times(b) 자유 자유
a / b div a.div(b) 자유 자유
a % b rem a.rem(b) 자유 자유
a += b plusAssign a.plusAssign(b) 자유 Unit
a -= b minusAssign a.minusAssign(b) 자유 Unit
a *= b timesAssign a.timesAssign(b) 자유 Unit
a /= b divAssign a.divAssign(b) 자유 Unit
a % = b remAssign a.remAssign(b) 자유 Unit
a > b compareTo a.compareTo(b) > 0 자유 Int
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0
a == b equals a?.equals(b) ?: (b === null) Any? Boolean
a != b equals !(a?.equals(b) ?: (b === null)) Any? Boolean

 

 

번호 붙은 접근 연산자 Indexed Access Operator []

[] 연산자는 표현식[표현식] 형태로 적으며, 객체의 일부 값을 추출할 때 사용한다.

아래 코드에서는 [] 연산자를 오버로딩하는 멤버 함수 get과 set을 선언하고 있다.

class Person(var name: String, var birthday: String) {
    operator fun get(position: Int) : String {
        return when (position) {
            0 -> name
            1 -> birthday
            else -> "알 수 없음"
        }
    }
    
    operator fun set(position: Int, value: String) {
        when (position) {
            0 -> name = value
            1 -> birthday = value
        }
    }
}

fun main(args: Array<String>) {
    val person = Person("Emily", "2020-02-01")
    println(person[0]) //Emily
    println(person[1]) //2020-02-01
    println(person[2]) //알 수 없음
    
    person[0] = "Kotlin"
    println(person.name) //Kotlin
}

person[0]은 컴파일 시 person.get(0)으로 번역된다.

person[0] = "Kotlin"은 컴파일 시 person.set(0, "Kotlin")으로 번역된다.

 

[] 연산자에는 여러 개의 피연산자를 지정할 수 있다.

person[1, 2, 3]은 person.get(1, 2, 3)이다.

person[1, 2] = "A"은 person.set(1, 2, "A")가 호출된다.

 

 

호출 연산자 Invoke Operator ()

()은 함수를 호출할 때 사용하는 연산자이다.

아래 코드에서는 () 연산자를 오버로딩하는 멤버 함수 invoke를 선언하고 있다.

class Product(val id: Int, val name: String) {
    operator fun invoke(value: Int) {
        println(value)
        println("id: $id, name: $name")
    }
}

fun main(args: Array<String>) {
    val product = Product(98765, "Kotlin Book")
    product(108)
    // 108
    // id: 98765, name: Kotlin Book
}

product(108)은 product.invoke(108)로 번역된다.

인수의 개수는 자유다. 인수는 아예 생략할 수도 있다.

 

 

in 연산자

in 연산자는 어떤 값이 객체에 포함되어 있는지 여부를 조사한다.

fun main(args: Array<String>) {
    println('o' in "Kotlin") //true
    println("in" !in "Kotlin") //false
}

'o' in "Kotlin"은 컴파일 시 "Kotlin".contains('o')로 변역된다.

in 연산자는 operator fun contains(매개변수: 타입) : Boolean 멤버 함수로 오버로딩할 수 있다.

매개변수의 타입은 자유롭게 지정할 수 있다.

in 연산자는 when 문에서도 쓸 수 있다.

 

 

멤버 함수의 중위 표기법 infix Notation

중위 표기법이란, 피연산자 연산자 피연산자 순서로 표현식을 구성하는 방식을 의미한다.

멤버 함수의 매개변수가 하나뿐이면 함수 호출을 중위 표기법으로 할 수 있다.

class Point(var x: Int = 0, var y = 0) {
    infix fun from(base: Point) : Point {
        return Point(x - base.x, y - base.y)
    }
}

fun main(args: Array<String>) {
    val p = Point(3, 6) from Point(1, 1)
    println(p.x) //2
    println(p.y) //5
}

 

 

상속 Inheritance

open class Person(val name: String, val age: Int)

class Student(name: String, age: Int, val id: Int) : Person(name, age)

fun main(args: Array<String>) {
    val person = Person("홍길동", 30)
    val student = Student("홍길봉", 28, 98765)
}

기본적으로 클래스는 상속이 막혀있다. (기본 final)

상속할 때는 반드시 슈퍼클래스의 생성자를 호출해야 한다.

두 개 이상의 클래스를 동시에 상속하는 것은 불가능하다.

 

 

업캐스팅 Upcasting

캐스팅 또는 형변환이란 어떤 타입을 다른 타입으로 변환하는 것을 의미한다.

서브클래스의 인스턴스를 슈퍼클래스의 타입으로 가리킬 수 있다.

open class Person(val name: String, val age: Int)

class Student(name: String, age: Int, val id: Int) : Person(name, age)

fun main(args: Array<String>) {
    val person: Person = Student("John", 33, 98765)
}

 

단순히 클래스를 확장하는 게 목표라면 확장 함수나 확장 프로퍼티를 이용하는 것이 더 낫다.

 

 

오버라이딩 Overriding

open Class AAA {
    open fun func() = println("AAA")
}

class BBB : AAA() {
    override fun func() {
        super.func()
        println("BBB")
    }
}

fun main(args: Array<String>) {
    AAA().func()
    // AAA
    BBB().func()
    // AAA
    // BBB
}

override 키워드는 그 자체로 open 키워드를 포함한다. 

재 오버라이딩을 막으려면 final 키워드를 붙여야 한다. (final override fun func())

 

 

프로퍼티 오버라이딩

open class AAA {
    open var number = 10
        get() {
            println("AAA getter...")
            return field
        }
        set(value) {
            println("AAA setter...")
            field = value
        }
}

class BBB : AAA() {
    override var number: Int
        get() {
            println("BBB getter...")
            return super.number
        }
        set(value) {
            println("BBB setter...")
            super.number = value
        }
}

fun main(args: Array<String>) {
    val test = BBB()
    test.number = 5
    // BBB setter...
    // AAA setter...
    test.number
    // BBB getter...
    // AAA getter...
}

val 프로퍼티를 var로 오버라이딩할 수 있다.

 

 

다형성 Polymorphism

open class AAA {
    open fun hello() = println("AAA...")
}

class BBB : AAA() {
    override fun hello() = println("BBB...")
}

fun main(args: Array<String>) {
    val one = AAA()
    val two = BBB()
    val three: AAA = two
    
    one.hello() //AAA...
    two.hello() //BBB...
    three.hello() //BBB...
}

오버라이딩된 멤버 함수를 호출하면 참조 변수가 실제로 가리키고 있는 객체의 멤버 함수가 호출된다.

멤머 함수를 호출하는 형태는 한 가지지만, 문맥에 따라 실제로 호출되는 함수가 다르다.

 

 

클래스 없이 특정 클래스를 상속하는 객체

open class Person(val name: String, val age: Int) {
    open fun print() {
        println("이름: $name")
        println("나이: $age")
    }
}

fun main(args: Array<String>) {
    val custom: Person = object: Person("Miranda", 45) {
        override fun print() {
            println("object...")
        }
    }
    
    custom.print() //object...
}

object 표현식으로 상속을 했다. 상속은 1회용이 된다.

 

 

Any 클래스

어떤 클래스가 아무 클래스도 상속하지 않으면 자동으로 Any 클래스를 상속한다. 

결국 모든 클래스는 간접적으로 Any 클래스를 상속하는 것이다.

 

Any 클래스의 세 가지 멤버 함수

open class Any {
    open operator fun equals(other: Any?) : Boolean
    open fun hashCode() : Int
    open fun toString() : String
}
  • equals : == 연산자 오버로딩하는 함수
  • hashCode : 객체 고유의 해시코드 반환하는 함수
  • toString : 객체의 내용을 String 타입으로 변환하는 함수
println(someobject)

println 함수는 전달한 인수가 String 타입이 아니면 내부적으로 println(someobject.toString())을 호출하기 때문에 println(someobject)만 써도 괜찮다.

 

 

예외 처리

코틀린에는 throws 키워드가 없다. 

자바에서는 throws가 있는 함수를 호출할 때마다 매번 함수 호출을 try-catch 블록으로 감싸야 한다. 

하지만 코틀린을 간결함을 중요시하는 언어이다. 코틀린에서 예외 처리는 이제 필수가 아닌 옵션이다.

 

 

Nothing 타입

Nothing 타입은 실행 흐름이 도달할 수 없는 구역을 나타내기 위한 특수 타입이다.

Nothing 타입은 어떠한 타입과도 호환된다.

fun throwing() : Nothing = throw Exception()

fun main(args: Array<String>) {
    println("start")
    val i: Int = throwing()
    println(i) //실행되지 않음
}

 

이런 코드를 도대체 언제 사용할까?

fun validate(num: Int) {
    val result: Int =
        if (num >= 0) num
        else throw Exception("num이 음수입니다.")
}

throw Exception()이 Nothing 타입의 표현식이기 때문에 if-else 블록이 Int 타입의 표현식으로 인식된다.

이렇게 Nothing 타입은 throw를 표현식으로 쓸 수 있게 하기 위한 장치이다.

 

 

Nullable 타입과 Null

타입 이름 뒤에 ?를 붙이면 변수를 Nullable하게 만들 수 있다.

자바는 기본적으로 모든 참조 타입에 null을 지정할 수 있지만, 코틀린에서는 Nullable 타입이 아니면 불가능하다.

 

null은 Nothing? 타입의 표현식이며, Nothing은 어떠한 타입과도 호환되므로 null을 Int? 타입에 대입할 수 있다.

 

Byte, Short, Int, Long, Float, Double, Char, Boolean 타입 뒤에 ?를 붙이면 그 변수는 참조 변수가 된다.

즉, 데이터가 스택 영역이 아닌 힙 영역에 생성된다.

 

val a = if (true) "Test" else null

위 코드에서 a는 어떤 타입일까? String과 Nothing?이 합쳐진 String? 타입으로 인식된다.

 

 

안전한 호출 연산자 Safe Call Operator

Nullable한 참조 변수의 프로퍼티와 멤버 함수에 접근하려면 반드시 . 대신 .? 연산자를 사용해야 한다.

var person: Person? = null
person?.name = "Mike"
person?.print() //무시됨
println(person?.name) //null

person = Person()
person?.name = "John"
person?.print() //실행됨
println(person?.name) //John

처음 print()는 person이 null이기 때문에 호출이 무시되며, person?.print() 표현식의 값은 null, 타입은 Unit?이 된다.

null이기 때문에 프로퍼티에 값을 집어넣는 동작 또한 무시된다. (사실 프로퍼티에 값을 저장하는 것도 일종의 함수 호출이다.)

 

 

Not-null 단정 연산자 Not-null Assertion Operator !!

!! 연산자는 Nullable 타입을 Not-null 타입으로 강제 캐스팅한다.

var obj: Building? = Building()
obj!!.name = "서울시청"
println(obj!!.name) //서울시청

obj = null
obj!!.print() //KotlinNullPointerException

처음에는 obj가 null이 아니기 때문에, obj!!는 무사히 Building 타입으로 캐스팅된다.

obj가 null이면 obj!!은 예외를 발생시킨다.

 

 

엘비스 연산자 Elvis Operator ?:

엘비스 연산자는 왼쪽의 피연산자가 null이 아니면 그 값을 그대로 쓰고, null이면 우측의 피연산자로 대체한다.

val number1: Int? = null
println(number1 ?: 0) //0

val number2: Int? = 15
println(number2 ?: 0) //15

 

 

스마트 캐스팅

특정 조건을 만족하는 경우, 컴파일러는 변수의 타입을 다른 타입으로 자동 캐스팅한다.

fun main(args: Array<String>) {
    val number1: Int? = null
    val number2 = 1000
    
    checkNull(number1) //null...
    checkNull(number2) //1000
}

fun checkNull(any: Any?) {
    if (any == null {
        println("null...")
        return
    }
    
    println(any.toString())
}

특정 변수에 null 값이 들어있지 않다는 것을 완벽히 추론할 수 있을 때 그 변수는 자동으로 Not-null 타입이 되어, any?.toString()이 아니라 any.toString()으로 호출할 수 있다.

 

 

is 연산자

is 연산자는 자바의 instanceof와 같다.

 

 

as 연산자와 다운캐스팅

다운캐스팅은 업캐스팅과 반대로 슈퍼클래스 타입을 서브클래스 타입으로 받는 것을 의미한다.

// Student는 Person을 상속한다.

val person1: Person = Student("John", 32, 98765)
val person2: Person = Person("Jack", 29, 98766)

var person3: Student = person1 as Student //캐스팅 성공
person3 = person2 as Student //캐스팅 실패 ClassCastException

person1 객체는 Student 타입이므로 무사히 캐스팅이 되지만,

person2 객체는 Person 타입이므로 예외가 발생한다.

 

캐스팅에 실패했을 때 예외가 발생하는 것을 막고 싶으면 as? 연산자를 대신 사용해야 한다.

as? 연산자는 캐스팅에 실패하면 null을 돌려준다.

person2 as? Student 표현식의 타입은 Student?이고,

캐스팅에 실패하면 null을, 성공하면 Student 타입으로 변환된 객체를 반환한다.

 

 

접근 지정자 Access Modifier

이름 의미
public 모든 곳에서 접근 가능하다.
접근 지정자를 생략하면 기본적으로 public이 된다.
internal 같은 모듈 안에서 접근 가능하다.
(여기서 모듈이란, intelliJ 프로젝트의 모듈을 가리킨다.)
protected 클래스 내부와 서브클래스에서 접근 가능하다.
private 프로퍼티와 멤버 함수일 경우, 해당 클래스 안에서만 접근 가능하다.
그 외의 경우, 같은 파일 안에서만 접근 가능하다.

다음과 같은 곳에 접근 지정자를 붙일 수 있다.

접근지정자 class 식별자 접근지정자 constructor(...) {
    접근지정자 val 식별자 ...
        접근지정자 get() = ...
        접근지정자 set(value) = ...
        
    접근지정자 constructor(...) : this(...) ...
    
    접근지정자 fun 식별자() ...
}

접근지정자 val 식별자 ...

접근지정자 fun 식별자(...) ...

 

 

접근 지정자 오버라이딩

오버라이딩을 통해 protected인 프로퍼티나 멤버 함수의 접근 지정자를 public으로 변경할 수 있다.

open class AAA(protected open val number: Int) {
    protected open fun hello() {
        println("Hello")
    }
}

class BBB(number: Int) : AAA(number) {
    public override val number: Int
        get() = super.number
        
    public override fun hello() = super.hello()
}

fun main(args: Array<String>) {
    val b = BBB(20)
    val a: AAA = b
    
    println(b.number) //20
    b.hello() //Hello
    println(a.number) //에러
    a.hello() //에러
}

private인 프로퍼티와 멤버 함수는 애초에 접근이 불가능하기 때문에 오버라이딩할 수 없다.

사실 private인 프로퍼티와 멤버 함수는 open 키워드 자체를 지정할 수가 없다.

 

 

확장 함수 Extension Function

String은 코틀린에 내장된 클래스가 멤버 함수를 추가할 수가 없다. 그렇다고 상속하는 것도 불가능하다. open 키워드가 붙어있지 않기 때문이다.

 

확장 함수를 사용하면 상속 없이 클래스 외부에서 멤버 함수를 추가할 수 있다.

fun String.isNumber() : Boolean {
    var i = 0
    while (i < this.length) {
        if (!('0' <= this[i] && this[i] <= '9')) { 
            return false
        }
        i += 1
    }
    return true 
}

fun main(args: Array<String>) {
    println("123456789".isNumber()) //true
    println("500$".isNumber()) //false
}

함수를 주입할 클래스를 리시버 Receiver 타입이라고 한다.

this를 사용해 리시버 타입의 프로퍼티나 멤버 함수에 접근할 수 있다.

단, private이거나 protected인 멤버에는 접근할 수 없다.

 

만약 클래스에 이미 존재하는 멤버 함수와 동일한 시그니처의 확장 함수가 있으면 확장 함수는 가려진다.

즉, 함수 호출시 항상 멤버 함수만 호출된다.

 

 

확장 프로퍼티 Extension Property

val String.isLarge: Boolean
    get() = this.length >= 10
    
fun main(args: Array<String>) {
    println("1234567890".isLarge) //true
    println("500$".isLarge) //false
}

확장 프로퍼티에는 Field가 존재하지 않으므로 field 식별자는 사용할 수 없다.