해당 글은
Kotlin in Action
도서를 읽으며 정리한 내용입니다
[ 널이 될 수 있는 타입 ]
- 개념
- 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 컬렉션은 그에 상응하는 Java 컬렉션 인터페이스의 인스턴스가 존재
- 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의배열
로 컴파일