Kotlin in Action - 6장

김정욱·2021년 10월 5일
0

Kotlin

목록 보기
5/5
post-thumbnail

해당 글은 Kotlin in Action 도서를 읽으며 정리한 내용입니다

[6장] 코틀린 타입 시스템

널 가능성(nullable)

[ 널이 될 수 있는 타입 ]

  • 개념
    • NPE(NullPointerException)을 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성
    • 널이 될 수 있는지 여부를 타입 시스템에 추가해서 컴파일러가 미리 감지하도록 구성
    • NPE를 실행시점에서 컴파일 시점으로 옮기는 것이 핵심
    • Kotlin은 Java와 다르게 널이 될 수 있는 타입(nullable type)을 명시적으로 지원
  • ? 연산자
    • 함수 / 특정 값이 널이 될 수 있도록 지정하는 연산자
    • 타입 이름 뒤에 명시
 /* 문자열 or null 값을 받을 수 있는 변수 */
 val victoria : String?

 /* 함수의 파라미터 인자로 2개의 nullable type을 받는 함수 */
 fun whiteSuede(price : Int?, count : Int?) : String {
   ...
 }
  • 고려해야할 사항
    • 변수.메소드 : null이 될 수 있기 때문에 함부로 메소드를 호출할 수 없다
      => 안전한 호출 연산자인 ?. 사용
    • 널이 될 수 없는 타입의 변수대입 : null이 될 수 없는 값에 함부로 대입할 수 없다
      => 엘비스 연산자인 ?: 사용

[ 타입의 의미 ]

  • 타입(Type)
    • 분류(classification)를 의미
    • 어떤 값이 가능한지에 대한 정보 + 수행할 수 있는 연산의 종류 를 결정
  • 변수에 타입을 지정한다는 것이란 ?
    • 컴파일러에게 해당 변수와 관련 연산 및 동작들이 성공적으로 실행될 수 있도록 확신을 주는 행위
  • Java에서 NPE 문제 개선을 위한 도구들
    • 애노테이션 (@Nullable / @NotNull)
      => 표준 자바 컴파일 절차의 일부가 아니기 때문에, 일관성 있게 적용된다는 보장을 할 수 없음
    • Optional
      • Java 8에 새로 도입된 기술
      • null을 감쌀 수 있는 특별한 래퍼타입
      • 코드가 지저분해지고, 실행 시점에 성능이 저하될 수 있다 (래퍼 타입의 사용)

[ 안전한 호출 연산자 ?. ]

  • 개념
    • null 검사와 메소드 호출을 한 번의 연산으로 수행하는 연산자
    • 프로퍼티를 읽거나 쓸 때도 안전하게 사용할 수 있는 연산자
  • 유의 사항
    • 안전한 호출의 결과 타입(return type)도 널이 될 수 있는 타입(nullable type) 이다
/* 메소드 호출 */
fun printAllCaps(s: String?){
  /* 안전한 호출 연산자인 ?.를 통한 호출 */
  val allCaps: String? = s?.toUpperCase() // s는 null일 수도 있다
  println(allCaps)
}

printAllCaps("abc") // ABC
printAllCaps(null) // null


/* 프로퍼티 호출 */
class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee) : String? = employee.manager?.name

val ceo = Employee("Da Boss", null)
val developer = Employee("Bob smith", ceo)

println(managerName(developer)) // Da Boss
println(managerName(ceo)) // null

[ 엘비스 연산자 ?: ]

  • 개념
    • null 대신 사용할 default value를 지정하는 연산자
    • 시계 방향으로 90도 회전하면 엘비스 프레슬리의 헤어스타일과 비슷해서 지어진 이름
fun strLenSafe(s: String?) : Int = s?.length ?: 0

println(strLenSafe("abc")) // 3
println(strLenSafe(null)) // 0
  • 활용
    • 함수의 전제 조건을 검사하는 목적으로 유용하게 활용
/* 클래스 정의 */
class Address(val streetAddress: String, val zipCode: Int,
              val city: String, val country: String)
class Company(val name: String, val address: Address?) // nullable type
class Person(val name: String, val company: Company?) // nullable type

fun printShippingLabel(person: Person){
  /* 안전한 호출 연산자 + 엘비스 연산자 */
  val address = person.company?.address
      ?: throw IllegalArgumentException("No address!")
  /* with 사용으로 수신 객체 지정 */
  with(address){
    println(streetAddress)  // 멤버변수 직접 호출
    println("$zipcode, $city, $country") // 멤버변수 직접 호출
  }
}

[ 안전한 캐스트 연산자 as? ]

  • as? 연산자
    • as를 nullable을 지정하는 ? 연산자와 함께 사용해서 안전한 캐스트를 수행하는 연산자
      (Kotlin에서 as는 타입 캐스트를 명시적으로 할 수 있는 연산자)
    • 일반적인 패턴으로 캐스트 수행 뒤 엘비스 연산자(?:)를 많이 사용
      => 원하는 타입 인지 검사 + 캐스트 + default value 지정 가능
    • is + as를 통한 안전한 캐스트와 비교
      => is 연산자로 타입을 확인한 뒤에 as로 캐스팅 할수도 있지만, as? 로 간결하게 제공
/* foo as? Type 의미 */
  foo is Type => foo as Type
  foo !is Type => null

[ 널 아님 단언 !! ]

  • !! 연산자
    • 어떤 값이든 널이 될 수 없는 타입으로 강제로 바꾸는 연산자
    • 컴파일러에게 널 값이 아니라고 단언하기 위한 목적으로 사용하는 연산자
  • 유의 사항
    • !! 를 통해 컴파일러에게 null값이 아니라고 단언했는데, 만약 null이라면 예외를 감수해야 한다
    • 확실하고 명백한 흐름이 아니라면 실무에서는 가급적 지양해야 한다
fun ignoreNulls(s: String?){
  val sNotNull: String = s!!
  println(sNotNull.length)
}

/* !!를 통해서 Null값이 아니라고 단언했는데, null이라서 오류 발생
   => 개발자가 감수해야 한다 */
ignoreNulls(null) // NPE 발생

[ let 함수 ]

  • 개념
    • 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우에 사용
    • 안전한 호출 연산자(?.)와 함께 사용해서, 널이 아닌 값이 필요한 함수를 수행할 수 있다
    • 즉, 수신 객체를 인자로 전달받은 람다로 넘기게 된다
fun sendEmailTo(email: String){
  println("Sending Email to ${email}")
}

/* let을 통해 null이 아니면 특정 람다 실행
   => let이 아니었으면 수행 전에 null을 검사하는 부분이 추가되어야 했을 것이다 */
var email: String? = "neity16@daum.net"
email?.let{ sendEmailTo(it) } // Sending Email to neity16@daum.net

email = null
email?.let{ sendEmailTo(it) } // 아무것도 실행되지 X

[ 나중에 초기화할 프로퍼티 ]

  • 개념
    • lateinit 변경자를 사용해서 프로퍼티를 나중에 초기화 할 수 있도록 설정
    • 프로퍼티를 나중에 초기화 하지 않고 사용하게 되면 오류가 발생
      => 어디가 잘못됐는지 확실하게 알 수 없는 NPE보다 더 좋다
class MyService{
  fun performAction() : String = "foo"
}

class MyTest{
  /* lateinit 변경자로 생성 후에 프로퍼티를 초기화 할 수 있도록 선언 */
  private lateinit var myService: MyService
  /* @Before 애노테이션을 통해 생성 후에 프로퍼티를 초기화! */
  @Before fun setUp(){
    myService = MyService()
  }

  @Test fun testAction(){
    /* lateinit으로 선언한 뒤, @Before로 후에 초기화 했으니 에러 발생 X */
    Assert.assertEquals("foo", myService.performAction())
  }
}

[ 널이 될 수 있는 타입 확장 ]

  • 개념
    • 널이 될 수 있는 타입을 확장하면, 확장 함수 내부에서 명시적으로 널 여부를 검사해서 확장 함수 자체를 사용할 때에 널 검사를 하지 않아도 된다
    • 직접 널이 될 수 있는 타입을 확장하는 함수를 만들면, 내부에서 명시적으로 널 검사를 하는 로직을 추가해서 사용하면 된다
    • 이미 정의된 예시
      => String? 타입에는 isNullOrEmpty / isNullOrBlank 확장 함수들이 있다
fun verifyUserInput(input: String?){
  /* input이 널이 될 수 있는 타입이지만, 확장함수인 isNullOrBlank에서 수신 객체를 검사한다
    => 즉, input 자체에서 함수를 호출할 때 널 검사를 하지 X */
  if(input.isNullOrBlank()){
    println("call success!")
  }
)


/* isNullOrBlank 함수 */
fun String?.isNullOrBlank() : Boolean = 
  this == null || this.isBlank()
  ...

[ 타입 파라미터의 널 가능성 ]

  • 개념
    • 코틀린에서 함수나 클래스의 모든 타입 파라미터(type parameter)는 기본적으로 널이 될 수 있다
    • 즉, 끝에 물음표가 없어도 널이 될 수 있는 타입임을 기억하고 있어야 한다
    • 타입 상한(upper bound)를 적절하게 지정해서 널이 아님을 명시할 수도 있다
/* 타입 파라미터 T는 기본적으로 null이 될 수 있다 */
fun <T> printHashCode(t: T){
  println(t?.hashCode()) // 안전한 호출 연산자 ?. 를 사용해야 한다
}


/* 타입 상한을 지정해서 널이 될 수 없도록 할 수 있다 */
fun <T: Any> printHashCode(t: T){
  println(t.hashCode()) // 안전한 호출 연산자 ?. 없어도 호출 가능!
}

[ 널 가능성과 자바 ]

  • Java의 @Nullable String = Kotlin의 String?
  • Java의 @NotNull String = Kotlin의 String
  • 플랫폼 타입
    • 코틀린이 널 관련 정보를 알 수 없는 타입
    • 따로 플랫폼 타입을 코틀린에서 선언할 수 없고, 자바 코드에서 가져온 타입이 플랫폼 타입이 된다
    • 컴파일러는 모든 연산을 허용
      => 플랫폼 타입의 사용에 대한 책임은 결국 개발자에게 있음
  • 플랫폼 타입의 필요성
    • 모든 타입을 널이 될 수 있도록 하면, 불필요한 널 검사가 반복적으로 들어가게 되기 때문
    • 프로그래머에게 타입을 제대로 처리할 책임을 부여하는 실용적인 접근 방법을 택한 것이다
  • Kotlin에서 Java 메소드를 오버라이드 할 때 ?
    • 메소드의 파라미터와 반환타입을 널이 될 수 있는 / 없는 타입으로 할지 결정해야 한다
    • 해당 코드를 다른 코틀린에서 호출할 수 있다
      => 코틀린 컴파일러는 널이 될 수 있는 타입으로 선언한 모든 파라미터에 대해 단언문을 만들어줌
/* Java interface */
interface StringProcessor {
  void process(String value);
}


/* Kotlin */
/* value를 널이 될 수 없는 타입으로 선언 */
class StringPrinter : StringProcessor {
  override fun process(value: String) {
    println(value) // 바로 사용 가능
  }
}

/* value를 널이 될 수 있는 타입으로 선언 */
class StringPrinter : StringProcessor {
  override fun process(value: String?) {
    /* null 값이 아닌지 검사한 뒤에 사용해야 한다 */
    if(value != null){
      println(value)
    }
  }
}

코틀린의 원시 타입

[ 원시 타입 : Int, Boolean 등 ]

  • Java
    : 원시타입과 참조타입을 구분한다
    • 원시 타입(primitive type) : 변수에 값이 직접 들어간다 / int, long 등
    • 참조 타입(reference type) : 메모리 상의 객체 위치가 들어간다 / String 등
  • Kotlin
    • 항상 같은 타입을 사용한다
    • 실행 시점에 가장 효율적인 방식으로 표현이 된다
      => 항상 객체로 표현되는 것이 아니라서 비효율적이지 않음
      => 대부분의 숫자 타입은 Kotlin의 Int 에서 Java의 int로 컴파일 된다
    • 타입 종류
      • 정수 타입 : Byte, Short, Int, Long
      • 부동소수점 수 타입 : Float, Double
      • 문자 타입 : Char
      • 불리언 타입 : Boolean

[ 널이 될 수 있는 원시 타입 : Int? Boolean? 등 ]

  • Kotlin의 널이될 수 있는 타입(nullable type)은 Java의 래퍼 타입으로 컴파일
    => Java의 원시 타입은 null 참조를 가질 수 없기 때문
  • Generic class의 경우 래퍼 타입으로 컴파일 된다
    => JVM은 타입 인자로 원시 타입을 허용하지 않기 때문

[ 숫자 변환 ]

  • Kotlin과 Java의 가장 큰 차이점 중 하나가 바로 숫자를 변환하는 방식
  • Kotlin은 다른 타입의 숫자로 자동 변환하지 않는다
    => 범위가 넓은 경우 조차도 자동 변환을 해주지 않음
    => 개발자의 혼란을 피하기 위해서 타입 변환을 명시한다
  • Kotlin은 모든 원시 타입(primitive type)에 대한 변환 함수를 제공한다
    • toByte()
    • toShort()
    • toChar() 등
val i = 1
val l: Long = i // 'type mismatch' 컴파일 오류 발생

/* 명시적으로 변환 함수를 제공 */
val l: Long = i.toLong()
  • 원시 타입 리터럴
    • L 접미사가 붙은 Long 타입 리터럴 : 123L
    • 표준 부동소수점 표기법을 사용한 Double 리터럴 : 0.12 / 2.0, 1.2e10
    • f나 F 접미사가 붙은 Float 리터럴 : 123.4f / .456F
    • 0x나 0X 접두사가 붙은 16진 리터럴 : 0xbcdL
    • 0b나 0B 접두가사 붙은 2진 리터럴 : 0b00000101
  • 컴파일러 지원
    • 숫자 리터럴에 대해서는 컴파일러가 필요한 변환을 자동으로 넣어준다
    • 산술 연산자도 적당한 타입의 값을 받아들일 수 있게 오버로드 돼어 있다
fun foo(l: Long) = println(l)

val b: Byte = 1 
val l: = b + 1L // 상수 값은 적절한 타입으로 해석, +는 Byte와 Long을 인자로 받는다
foo(42) // 컴파일러가 42를 Long값으로 해석

[ Any, Any? : 최상위 타입 ]

  • Any 타입
    • Kotlin에서 원시 타입을 포함한 모든 타입의 조상 타입
    • Java의 Object와 유사하지만, Object는 참조 타입만 포함(원시타입 포함 X)
    • 기본적으로 널이 될 수 없는 타입이다(non-nullable type)
  • Any? 타입
    • Kotlin에서 널을 포함하는 모든 값을 대입할 수 있는 변수 타입
val answer1: Any = 42 
val answer2: Any? = null

[ Unit 타입 : 코틀린의 void ]

  • Unit 타입
    • Java의 void와 유사하며 같은 기능을 하는 타입
    • 전혀 반환하지 않는 함수의 반환 타입으로 사용 가능
    • void와 차이점
      • Unit -> 모든 기능을 갖는 일반적인 타입 / 타입 인자로 사용 가능
      • void -> 타입 인자로 사용 불가능
/* 아래 두 함수는 동일한 표현 */
fun f() : Unit { ... }
fun f() { ... }

/* 타입 인자로 사용되는 Unit */
interface Processor<T>{
  fun process() : T
}

// 타입 인자로 Unit을 사용하지만, 반환 타입을 지정할 필요는 X
class NoResultProcessor : Processor<Unit> { 
  override fun process() {
    // 업무 처리 코드
    // return을 명시할 필요가 없다 => 컴파일러가 묵시적으로 return Unit을 해주기 때문
  }
}

[ Nothing 타입 : 이 함수는 결코 정상적으로 끝나지 않는다 ]

  • Nothing 타입
    • 결코 성공적으로 값을 돌려주지 않아서, '반환 값'이라는 개념 자체가 없는 것을 의미
    • 아무 값도 포함하지 않는다
    • 함수의 반환 타입 / 타입 파라미터로만 사용 가능
    • ex) 예외를 던져 테스트를 실패시키는 fail 라이브러리 등
/* 함수의 반환 타입을 Nothing으로 지정 */
fun fail(message: String) : Nothing {
  throw IllegalStateException(message)
}

fail("Error occurred!")

/* 엘비스 연산자와 함께 사용 가능 */
val address = company.address ?: fail("No address")
println(address.city)

컬렉션과 배열

[ 널 가능성과 컬렉션 ]

  • 컬렉션에서 널이 될 수 있는 주체가 누군지에 따라 명확하게 이해해야 한다
/* 널 값을 가질 수 있는 컬렉션 */
List<Int?>

/* 전체 리스트 자체가 널이 될 수 있는 컬렉션 */
List<Int>?

/* 널이 될 수 있는 값으로 이루어진, 널이 될 수 있는 리스트 */
List<Int?>?
  • filterNotNull 함수
    • 널이될 수 있는 값을 가지는 컬렉션에서, 원소를 걸러내는 작업을 해주는 함수
fun addValidNumbers(numbers: List<Int?>) {
  /* numbers 원소 중 null이 아닌 값들만을 가진 새로운 컬렉션 반환
     => 반환 타입은 List<Int> */
  val validNumbers = numbers.filterNotNull()
  ...
]

[ 읽기 전용과 변경 가능한 컬렉션 ]

  • Kotlin의 컬렉션 인터페이스 : Java와 다르게 컬렉션을 2가지로 분리
    • Collection
      • 최초 생성 후, 컬렉션의 데이터를 읽기만 가능한 인터페이스
      • 원소를 추가, 제거 하는 메소드가 없다
      • kotlin.collections.Collection
    • MutableCollection
      • 컬렉션의 데이터를 변경할 수 있는 인터페이스
      • Collection 인터페이스를 확장하면서 원소를 추가, 제거도 가능
      • kotlin.collections.MutableCollection
  • Kotlin에서 컬렉션을 분리한 이유
    • 프로그램에서 데이터에 어떤 일이 벌어지는지를 더 쉽게 이해하기 위해서
    • 함수의 인자로 전달할 때 변경 여부에 따라 안전성도 확보할 수 있다
  • 주의할 점
    • 같은 컬렉션 객체를 동시에 참조하는 2가지 참조(읽기전용 / 변경 가능) 가 있다면 변경 가능
      => 즉, 읽기 전용 컬렉션이더라도 항상 스레드 안전하지 않다는 점을 기억해야 한다
    • Kotlin 에서 읽기 전용 컬렉션으로 선언되어도, Java 코드에서는 내용을 변경할 수 있다
      => 즉, Java로 넘기는 코틀린의 컬렉션의 데이터 정합성에 대한 책임은 프로그래머에게 있다
      => 같은 맥락으로, 널이 아닌 원소로 이루어진 컬렉션을 Java에서 받고 null을 넣을 수도 있다

[ 코틀린 컬렉션과 자바 ]

  • Kotlin 컬렉션 생성 함수
    • List
      • 읽기 전용 타입 : listOf()
      • 변경 가능 타입 : mutableListOf() / arrayListOf()
    • Set
      • 읽기 전용 타입 : setOf()
      • 변경 가능 타입 : mutableSetOf() / hashSetOf() / linkedSetOf() / sortedSetOf()
    • Map
      • 읽기 전용 타입 : mapOf()
      • 변경 가능 타입 : mutableMapOf() / hashMapOf() / linkedMapOf() / sortedMapOf()

[ 컬렉션을 플랫폼 타입으로 다루기 ]

  • 플랫폼 타입 (with. 널 가능성, 변경 가능성)
    • Kotlin에서 사용하는 Java쪽에서 정의한 타입
    • 널이 될 수 있는지 알 수 없기에, 제약이 없어서 오롯이 프로그래머가 책임져야 한다
    • 같은 맥락으로, 변경 가능성에 대해 알 수 없어서 어떤 컬렉션 타입으로든 다룰 수 있다
      => 역시 책임은 프로그래머가 진다
  • 플랫폼 타입을 Kotlin에서 사용시 고려할 사항
    • 널 가능성
      • 컬렉션이 널이 될 수 있는가 ?
      • 컬렉션의 원소가 널이 될 수 있는가 ?
    • 변경 가능성
      • 오버라이드하는 메소드가 컬렉션을 변경할 수 있는가 ?
/* Java */
interface DataParser<T> {
  void parseData(String input, List<T> output);
}

/* Kotlin */
class PersonParser : DataParser<Person> {
  /* 맥락 상, output에는 널 값의 원소가 들어가지 않다는 전제가 있고, 변경 가능해야 한다
     => 널 값을 가지지 못하는 컬렉션 + 변경 가능한 컬렉션 */
  override fun parseData(input: String, output: MutuableList<Person>){
    ...
  }
}

[ 객체의 배열과 원시 타입의 배열 ]

  • Kotlin에서 배열
    • 개념 & 특징
      • 타입 파라미터를 받는 클래스
      • 배열의 원소 타입은 바로 그 타입 파라미터에 의해 결정
    • 생성 방법
      • arrayOf()
      • arrayOfNulls() : 인자로 정수값을 넘기면, 모든 원소가 null이고 정수의 크기를 갖는 배열을 반환
      • Array 생성자 : 배열 크기와, 람다를 인자로 받아서 람다로 각 배열 원소를 초기화
/* a ~ z까지 원소를 갖는 배열 생성 */
val letters = Array<String>(26) {i -> ('a' + i).toString()}
println(letters.joinToString(""))
// abcdefghijklmnopqrstuvwxyz
  • Java의 main 함수의 표준 시그니처
/* Kotlin에서 배열 표기 */
fun main(args: Array<String>){
  ...
}
  • 컬렉션 -> 배열 변환
    • toTypedArray()
/* 배열로 변환한 후, spread 연산자 *를 이용해서 분리 */
val strings = listOf("a", "b", "c")
println("%s, %s, %s".format(*strings.toTypedArray()))
  • Kotlin은 원시 타입의 배열을 표시하는 별도의 클래스를 제공
    • IntArray : int[]로 컴파일
    • ByteArray : byte[]로 컴파일
    • CharArray : char[]로 컴파일
    • BooleanArray : boolean[]로 컴파일

[ 요약 ]

  • 코틀린은 널이 될 수 있는 타입을 지원해 NPE 오류컴파일 시점에 감지
  • 코틀린의 안전한 호출(?.), 엘비스 연산자(?:), 널 아님 단언(!!), let 함수 등을 통해 널이 될 수 있는 타입을 간결한 코드로 다룰 수 있다
  • as? 연산자를 통해 값을 다른 타입으로 변환하는 것 + 변환 불가능한 경우 처리 를 함께 가능
  • Java에서 가져온 타입은 Kotlin에서 플랫폼 타입으로 취급
  • 널이 될 수 있는 원시 타입(Int? 등)은 Java의 박싱한 원시 타입(Integer 등)에 대응
  • Any 타입은 모든 타입의 조상이며, Java의 Object에 해당
  • Unit은 Java의 void와 유사
  • 정상적으로 끝나지 않는 함수의 반환 타입Nothing을 사용
  • Kotlin 컬렉션읽기 전용 컬렉션변경 가능한 컬렉션을 구분
  • Kotlin의 Array 클래스는 Java의 배열로 컴파일
profile
Developer & PhotoGrapher

0개의 댓글