▩목 차▩
1. 널 가능성
1-1. 널이 될 수 있는 타입
1-2. 타입의 의미
1-3. 안전한 호출 연산자: ?.
1-4. 엘비스 연산자: ?:
1-5. 안전한 캐스트: as?
1-6. 널 아님 단언: !!
1-7. let 함수
1-8. 나중에 초기화할 프로퍼티
1-9. 널이 될 수 있는 타입 확장
1-10. 타입 파라미터의 널 가능성
1-11. 널 가능성과 자바
1-11-1. 플랫폼 타입
1-11-2. 상속
1-12. 널 가능성에 대한 논의 정리
2. 코틀린의 원시 타입
2-1. 원시타입: Int, Boolean
2-2. 널이 될 수 있는 원시 타입: Int?, Boolean? 등
2-3. 숫자 변환
2-4. Anym Any?: 최상위 타입
2-5. Unit 타입: 코틀린의 void
2-6. Noting 타입: 이 함수는 결코 정상적으로 끝나지 않는다.
3. 컬렉션과 배열
3-1. 널 가능성과 컬렉션
3-2. 읽기 전용과 변경 가능한 컬렉션
3-3. 코틀린 컬렉션과 자바
3-4. 컬렉션을 플랫폼 타입으로 다루기
3-5. 객체의 배열과 원시 타입의 배열
4. 정리
이번장에서는 타입 시스템을 살펴보자.
자바와 비교해보자면 코틀린의 타입 시스템은 코드의 가독성을 향상시키는데 도움이 되는 몇 가지 특성을 새로 제공한다.
==> 그런 특성으로는 널이 될 수 있는 타입과 읽기 전용 컬렉션이 있다.
또한 코틀린은 자바 타입 시스템에서 불필요하거나 문제가 되던 부분(배열 지원)을 제거했다.
■ 1. 널 가능성 ■
널 가능성(nullabiltiy)은 NullPointerException 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다.
코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제를 실행 시점에서 컴파일 시점으로 옮기는 것이다.
널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있다.
널이 될 수 있는 타입에 대해 알아보고, 코틀린에서 널이 될 수 있는 값을 어떻게 표기하고 코틀린이 제공하는 도구가 그런 널이 될 수 있는 값을 처리하는지 알아보자.
또한 널이 될수 있는 측면에서 코틀린과 자바 코드를 어떻게 함께 사용할 수 있는지 살펴보자.
■ 1-1. 널이 될 수 있는 타입
코틀린과 자바의 가장 중요한 차이는 코틀린은 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점이다.
==> 이말은 즉슨, 널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법이다.
어떤 변수가 널이 될 수 있다면, 그 변수에 대해 메소드를 호출하면 NullPointerException이 발생할 수 있으므로 안전하지 않다.
코틀린은 그런 메소드 호출을 금지함으로써 많은 오류를 방지한다.
일단 자바의 함수를 한번 살펴보자.
/* 자바 */
int strLen(String s) {
return s.length();
}
위의 자바 코드를 보자. 안전해 보이는가?
==> 이 함수에 null을 넘기면 NullPointerException이 발생한다. 그렇다면 이 함수에서 s가 null인지 꼭 검사해야 할까? [ 검사 여부는 함수를 사용하는 의도에 따라 달라지긴 한다. ]
위의 코드를 다시 코틀린으로 작성해보자. 코틀린에서 이런 함수를 작성할 때 가장 먼저 생각해야할 것은 "이 함수가 널을 인자로 받을 수 있는가?" 이다. 여기서 널을 인자로 받을 수 있다는 말은 strLen(null)처럼 직접 null 리터럴을 사용하는 경우뿐 아니라 변수나 식의 값이 실행 시점에 null이 될 수 있는 경우를 모두 포함한다.
널이 인자로 들어올 수 없다면 코틀린에서는 다음과 같이 함수를 정의 할 수 있다.
fun strLen(s: String) = s.length
fun main(args: Array<String>){
strLen(null)
}
Null can not be a value of a non-null type String
위의 코드는 strLen에 null이거나 널이 될 수 있는 인자를 넘기는 것은 금지되며, 혹시 그런 값을 넘기면 컴파일 시 오류가 발생한다.
strLen 함수에서 파라미터 s의 타입은 String인데 코틀린에서 이는 항상 String의 인스턴스여야 한다는 뜻이다. 이 때 컴파일러는 널이 될 수 있는 값을 strLen에게 인자로 넘기지 못하게 막는다.
==> 따라서 strLen 함수가 실행 시점에 NullPointerException을 발생시키지 않으리라 장담 할 수 있다.
위에 있는 함수가 널과 문자열을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음료(?)를 명시해야 한다.
fun strLenSafe(s: String?) = s?.length
위 코드를 보자.
String?, Int?, MyCustomType? 등 어떤 타입이든 타입 이름 뒤에 물음표(?)를 붙이면 그 타입의 변수난 프로퍼티에 null 참조를 저장할 수 있다는 뜻이다.
즉, 바꿔말한다면 물음표가 없는 타입은 그 변수가 null 참조를 저장할 수 없다는 뜻이다. 따라서 모든 타입은 기본적으로 널이 될 수 없는 타입이다.
널이 될 수 있는 타입의 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한된다.
예를들어, 널이 될 수 있는 타입인 변수에 대해 변수.메소드()처럼 메소드를 직접 호출할 수는 없다.
fun strLenSafe(s: String?) = s.length
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
위의 코드를 보면 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입 할 수 없다.
또한 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다.
fun main(args: Array<String>){
val x : String? = "22"
strLen(x)
}
Type mismatch: inferred type is String? but String was expected
위처럼 널 여부 변수에 따른 대입 및 전달이 안되는데 , 즉 널이 될 수 있는 타입의 값으로 대체 뭘 할 수 있을까?
==> 가장 중요한 일을 바로 null과 비교하는 것이다. 일단 null과 비교하고 나면 컴파일러는 그 사실을 기억하고 null이 아님이 확실한 영역에서는 해당 값을 널이 될 수 없는 타입의 값처럼 사용할 수 있다.
예를 보자.
fun strLenSafe2(s: String?): Int = if(s != null) s.length else 0
fun main(args: Array<String>){
val x : String? = null
println(strLenSafe2(x))
println(strLenSafe2("abc"))
}
널 가능성을 다루기 위해 사용할 수 있는 도구가 if검사뿐이라면 코드가 번잡해지는 일을 피할 수 없을 것이다.
==> 다행히 코틀린은 널이 될 수 있는 값을 다룰 때 도움이 되는 여러 도구를 제공한다.
그런 도구를 살펴보기 전 널 가능성과 변수 타입의 의미에 대해 알아보자.
■ 1-2. 타입의 의미
타입이란 무엇이고 왜 변수에 타입을 지정해야 할까?
==> 타입은 분류로, 어떤 값들이 가능하지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다 라고 위키피디아글에 적혀있다.
이런 정의를 바탕으로 자바 타입 중 몇 가지에 대해 예를 들어본다면, double 타입 경우 64비트 부동소수섬 수다. double 타입의 값에 대해 일반 수학 연산, 및 함수를 적용할 수 있다. ==> 따라서 double 타입의 변수가 있고 그 변수에 대한 연산을 컴파일러가 통과시킨 경우 그 연산이 성공적으로 실행되었다고 확신할 수 있다.
double 타입과 String 타입의 변수를 비교해보자.
자바에서 String 타입의 변수에는 String이나 null이라는 두 가지 종류의 값이 들어갈 수 있다. 이 두 종류의 값은 서로 완전히 다르다.
심지어 자바의 instanceof 연산자도 null이 String이 아니라고 답한다. 두 종류의 값에 대해 실행할 수 있는 연산도 완전히 다르다.
실제 String이 들어있는 변수에 대해서는 String 클래스에 정의된 모든 메소드를 호출 할 수 있다. 하지만 null이 들어있는 경우에는 사용할 수 있는 연산이 많지 않다.
==> 위에서 말한 내용들은 자바의 타입 시스템이 널을 제대로 다루지 못한다는 뜻으로 받아들여도 된다. 변수에 선언된 타입이 있지만(예시로 String) 널 여부를 추가로 검사하기 전에는 그 변수에 대한 연산을 수행할 수 있을지 알 수 없다. 그렇기 위해 null 검사를 하기도 하지만 잊어버리고 안하는 경우가 자주 잇는데 이때 NullPointerException 예외를 발생시키며 오류로 중단된다.
코틀린의 널이 될 수 있는 타입은 위에 있는 자바의 문제에 대해 해법을 제공한다.
==> 널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할 지 명확히 이해할 수 있고, 실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있다. 따라서 그런 연산을 아예 금지시킬 수 있다.
[ 실행 시점에 널이 될 수 있는 타입이나 널이 될 수 없는 타입의 객체는 같다. 널이 될 수 있는 타입은 널이 될 수 없는 타입을 감싼 래퍼 타입이 아니다. 모든 검사는 컴파일 시점에 수행된다. 따라서 코틀린에서는 널이 될 수 있는 타입을 처리하는 데 별도의 실행 시점 부가 비용이 들지 않는다. ]
■ 1-3. 안전한 호출 연산자: ?.
코틀린이 제공하는 가장 유용한 도구 중 하나가 안전한 호출 연산자인 ?.이다.
?.은 null 검사와 메소드 호출을 한 번의 연산으로 수행한다.
예를들어, s?.toUpperCase()는 훨씬 더 복합한 if (s !=null) s.toUpperCase() else null과 같다.
==> 즉, 호출하려는 값이 null이 아니라면 ?. 뒤에 잇는 메소드를 정상 호출하고 null이라면 null이 결과값이 된다.
안전한 호출의 결과 타입도 널이 될 수 있는 타입이라는 사실에 유의해야 한다!
String.toUpperCase는 String 타입의 값을 반환하지만 s가 널이 될 수 있는 타입인 경우 s.toUpperCase() 식의 결과 타입은 String?이다.
fun printAllCaps(s: String?) {
val allCaps: String? = s?.toUpperCase() //allCaps는 널일 수도 있다.
println(allCaps)
}
fun main(args: Array<String>){
printAllCaps("abc")
printAllCaps(null)
}
메소드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있다.
아래 예제는 널이 될 수 있는 프로퍼티가 있는 간단한 코틀린 클래스로 프로퍼티 접근 시 안전한 호출을 사용하는 방법을 보여준다.
[EX] - 널이 될 수 있는 프로퍼티를 다루기 위해 안전한 호출 사용하기
class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee) : String? = employee.manager?.name
fun main(args: Array<String>){
val ceo = Employee("Da boss", null)
val developer = Employee("Bob Smith", ceo)
println(managerName(developer))
println(managerName(ceo))
}
Da boss
null
객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있다면 한 식 안에서 안전한 호출을 연쇄해서 함께 사용하면 편할 때가 자주 있다.
예를들어, 어떤 사람에 대한 정보와 그 사람이 다니는 회사에 대한 정보, 그리고 그 회사의 주소에 대한 정보를 각각 다른 클래스로 표현한다고 가정할때, 회사나 주소는 모두 생략 가능하다. ?.연산자를 사용하면 다른 추가 검사 없이 Person의 회사 주소에서 country 프로퍼티를 단 한 줄로 가져올 수 있다. 아래 예제를 보며 이해하자.
[EX] - 안전한 호출 연쇄시키기 [ Address, Company, Person 클래스 연쇄 ]
class Address(val streetAddress: String, val zipCode: Int,
val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun Person.countryName() : String {
val country = this.company?.address?.country
return if (country !=null) country else "Unknown"
}
fun main(args: Array<String>){
val address = Address("9-12", 1111,"donghae","korean")
val company = Company("comany",address )
val person = Person("Alice", company)
val person2 = Person("Dmitry", null)
println(person.countryName())
println(person2.countryName())
}
korean
Unknown
널 검사가 들어간 구문은 자바 코드에서 자주 볼 수 있다. 이 자바 코드에는 불필요한 동작이 들어있다 [ country가 null인지 검사해서 정상적으로 얻은 country 값을 반환하거나 null인 경우에 대응하는 "Unknown"을 반환한다. ] 코틀린을 사용하면 이런 불필요한 If문도 없앨 수 있다.
■ 1-4. 엘비스 연산자: ?:
코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공한다. 그 연산자는 엘비스 연산자(널 복합)라고 한다.
엘비스 연산자를 사용하는 방법이다.
fun foo(s: String?) {
val t: String = s ?: "" //"s"가 null이면 결과는 빈 문자열이다.
엘비스 연산자는(?:) 일단 변수가 널인지 검사를 하고 널이 아니면 변수에 값을 넣고, 변수가 null이면 엘비스 연산자(?:) 뒤에 있는 디폴트 값을 사용한다.
fun strLenSafe2(s: String?): Int = if(s != null) s.length else 0
fun main(args: Array<String>){
val x : String? = null
println(strLenSafe2(x))
println(strLenSafe2("abc"))
}
위에 있는 코드를 엘비스 연산자(?:)를 활용해 줄여 써보자.
[EX] - 엘비스 연산자를 활용해 널 값 다루기
package list6
fun strLenSafe3(s:String?): Int = s?.length ?: 0
fun main(args: Array<String>){
println(strLenSafe3("abc"))
println(strLenSafe3(null))
}
3
0
위의 코드와 같이 쉽게 줄여 쓸 수 있다.
fun Person.countryName() : String {
val country = this.company?.address?.country
return if (country !=null) country else "Unknown"
}
또한 위의 코드를 줄여본다면, 아래와 같이 한줄로 줄일 수 있다.
fun Person.coutryName() = company?.address?.country ?: "Unknown"
위의 코드들을 보면 알 수 있듯이 코틀린에서는 retrun이나 throw등의 연산도 식이다.
==> 따라서 엘비스 연산자의 우항에 return, throw등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 쓸 수 있다. 이런 경우 엘비스 연산자의 좌항이 널이면 함수가 즉시 어떤 값을 반환하거나 예외를 던진다. [ 이런 패턴은 함수의 전제 조건을 검사하는 경우 특히 유용 ]
다음예로, 엘비스 연산자를 활용해서 지정한 사람의 회사 주소를 라벨에 인쇄하는 함수를 만들어보자.
package list6
class Address2(val streetAddress: String, val zipCode: Int,
val city: String, val country: String)
class Company2(val name: String, val address: Address2?)
class Person2(val name: String, val company: Company2?)
fun printShippingLabel(person:Person2){
val address = person.company?.address
?: throw IllegalArgumentException("No address") //주소가 없으면 예외를 발생시킨다.
with(address) {//adress는 널이 아니다.
println(streetAddress)
println("$zipCode $city, $country")
}
}
fun main(args: Array<String>){
val address = Address2("9-12", 1111,"donghae","korean")
val company = Company2("comany",address )
val person = Person2("Alice", company)
val person2 = Person2("Dmitry", null)
println(printShippingLabel(person))
println(printShippingLabel(person2))
}
9-12
1111 donghae, korean
kotlin.Unit Exception in thread "main" java.lang.IllegalArgumentException: No address at list6.List6_6Kt.printShippingLabel(list6_6.kt:11) at list6.List6_6Kt.main(list6_6.kt:26)
위의 코드를 보자
printShippingLabel 함수는 모든 정보가 제대로 있으면 주소를 출력한다. 주소가 없으면(null)이면 그냥 NullPointerException을 던지는 대신에 의미 있는 오류를 발생시킨다.
만약 주소가 있다면 라벨은 거리 주소, 우편번호, 도시, 나라 순으로 출력되게 했다. 또한 with 함수를 사용했기 때문에 address를 한 줄에서 네 번이나 반복하지 않게 썼다.
지금까지 코틀린에서의 if-not-null 검사를 수행하는 방법을 살펴봤다.
■ 1-5. 안전한 캐스트: as?
자바 Instanceof 검사 대신 코틀린이 제공하는 더 안전한 타입 캐스트 연산자를 살펴보자.
전에 as를 배웠는데, 자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생한다.
[ 물론 as를 사용하기전에 is를 통해 미리 as로 변환 가능한 타입인 지 검사 해볼 수 있다. ]
==> 코틀린은 더 좋은 해법을 제공한다.
as? 연산자는 어떤 값을 지정한 타입으로 캐스트한다. as?는 값을 대상 타입으로 반환할 수 없으면 null을 반환한다.
[ 그냥 as를 이용하여 지정한 타입을 바꿀 수 없으면 ClassCastException이 발생하여 확실히 as?를 써서 null을 반환하는것이 좋아보이는 방법이다. ]
안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자를 사용하는 것이다.
예를들어, equals를 구현할 때 이런 패턴이 유용하다.
class Person(val firstName: String, val lastName: String){
override fun equals(other: Any?): Boolean {
val otherPerson = other as? Person ?: return false //타입이 서로 일치하지 않으면 기본값인 false를 반환한다.
return otherPerson.firstName == firstName && //안전한 캐스트를 하고나면 otherPerson이 Person 타입으로 스마트 캐스트 된다.
otherPerson.lastName == lastName
}
override fun hashCode(): Int {
return firstName.hashCode() * 37 + lastName.hashCode()
}
}
fun main(args: Array<String>){
val p1 = Person("Dmitry", "Jemerov")
val p2 = Person("Dmitry", "Jemerov")
println(p1 == p2) //== 연산자 "equals" 메소드를 호출한다.
println(p1.equals(42)) //override한 equals를 호출
}
true
false
위의 예제를 보자.
[ as?(안전한 타입 캐스트)와 ?:(엘비스 연산자)를 이용하여 타입을 캐스트 할 수 없다면 ?:(엘비스 연산자)의 디폴트 값인 false를 리턴하고 캐스트가 가능하면 캐스트를 한다. 그 후 캐스트가 성공이 되면 return 을 이용해 캐스팅 한 값들의 이름과 마지막 이름들을 비교한다. ]
정리해본다면, 파라미터로 받은 값이 원하는 타입인지 쉽게 검사하고 캐스트 할 수 있고, 타입이 맞지 않으면 쉽게 false를 반환할 수 있다.
타입을 검사한 후 null 값을 거부하고 나면(엘비스 연산자를 이용해서 가능) 컴파일러가 otherPerson 변수의 값이 Person이라는 사실을 알고 적절히 처리해줄 수 있다.
안전한 호출, 안전한 캐스트, 엘비스 연산자는 유용하기 때문에 코틀린에 자주 나타난다. 하지만 코틀린의 널 처리 기능을 활용하는 대신 직접 컴파일러에게 어떤 값이 널이 아니라는 사실을 알려주고 싶을때 어떻게 하는지 알아보자.
■ 1-6. 널 아님 단언: !!
널 아님 단언(not-null assertion)은 코틀린에서 널이 될 수 있는 타입의 값을 다룰 때 사용할 수 있는 도구 중에서 가장 단순하면서도 무딘 도구다.
느낌표를 (!!)으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로 (강제로) 바꿀 수 있다. 실제 널에 대해 !!를 적용하면 NPE가 발생한다.
다음예로 널이 될 수 있는 인자를 널이 될 수 없는 타입으로 변환하는 예제를 살펴보자.
[EX] - 널 아님 단언 사용하기
package list6
fun ignoreNulls(s: String?) {
val sNotNull: String = s!! //예외는 이 지점을 가리킨다
println(sNotNull.length)
}
fun main(args: Array<String>){
ignoreNulls(null)
}
Exception in thread "main" java.lang.NullPointerException
위 코드의 결과는 NullPointerException의 예외를 던진다.
하지만 발생한 예외는 null 값을 사용하는 코드(sNotNull.length가 있는 줄)가 아니라 단언문이 위치한 곳을 가리킨다.
==> 근본적으로 !!는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다"라고 말하는 것이다.
쉽게 말해서 널 아님 단언문(!!)은 널 값을 허용한(?) 변수를 널 값이 아닌 변수(아무런 단언문이 적혀있지 않은 일반 변수)로 바꿔주는 것이다.
이러한 널 아님 단언문(!!)이 좋은 경우가 있다. 어떤 함수가 널인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없다. 하지만 이런 경우 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는다는 사실이 분명하다면 굳이 널 검사를 다시 수행하지 않을 것이다. 이럴 때 널 아님 단언문을 쓸 수 있다.
널 아님 단언(!!)을 사용하는 스윙[ 자바에서 지원하는 GUI프로그램으로 사용자 편의를 위하여 코드위에 그래픽 프로그램을 덮입힌것 ] 액션 예제를 보자.
copyRowAction클래스는 리스트 컨트롤에서 선택된 줄을 클립보드에 복사한다고 가정하자. 설명에 불 필요한 자세한 부분은 생략하고 어떤 줄이 선택됐는지 여부를 검사(줄이 선택되면 액션을 실행할 수 있다는 뜻이다.)하고 선택된 줄의 정보를 가져오는 부분만을 남겼다. 액션 API는 isEnabled가 true인 경우 actionPerformed를 호출해준다고 가정하자.
[EX] - 스윙 액션에서 널 아님 단언 사용하기
package list6
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.JList
class CopyRowAction(val list: JList<String>) : AbstractAction( ){
override fun isEnabled(): Boolean =
list.selectedValue != null
override fun actionPerformed(e: ActionEvent?) { //actionPerformed는 isEnabled가 "ture"인 경우에만 호출된다.
val value = list.selectedValue!! //value를 클립보드로 복사
}
}
위의 코드를 보자.
위의 코드에서 val value = list.selectedValue 부분에서 !!를 사용하지 않으려면 val value = list.selectedValue ?: return 처럼 널이 될 수 없는 타입의 값을 얻어야 한다. 이런 패턴을 사용하면 list.selectedValue가 null이면 함수가 조기 종료되므로 함수의 나머지 본문에서는 value가 항상 널이 아니게 된다. 이 식에서 엘비스 연산자는 중복이라 할 수 있지만 나중에 isEnabled가 더 복잡해질 가능성에 대비해 미리 보호 장치를 마련해 둔다고 생각할 수도 있다.
또한 !!를 널에 대해 사용해서 발생하는 예외 메시지는 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤식에서 예외가 발생했는지에 대한 정보가 들어있지 않기 때문에 어떤 값이 널이었는지 확실히 하기 위해 여러 !! 단언문을 한 줄에 함께 쓰는 일은 피해야 한다.
person.company!!.address!!.country //이런 식(!! 단언문을 함께 쓰는)으로 코드를 작성하면 안된다.
지금까지 널이 될 수 있는 타입(?)의 값에 어떻게 접근하는지에 대해 알아보았다. [ 널이 될 수 있는 타입의 값에 접근하기 위해서는 값을 초기화 하려는 부분도 널이 될 수 있는 타입이거나, 혹은 널이 될 수 있는 타입으로 만들기 위해 !! 단언문을 이용한다. ]
■ 1-7. let 함수
널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기려면 어떻게 해야 할까?( 그런 호출은 안전하지 않기 때문에 컴파일러는 오류를 보낸다.)
==> 코틀린 언어는 이런 경우 특별한 지원을 제공하지 않지만, let 함수를 이용하여 위의 문제를 해결할 수 있다.
let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.
예를들어, 이메일을 보내는 sendEmailTo 함수가 이메일 주소를 String 타입의 파라미터로 받는다고 할 때, 이 함수는 코틀린으로 작성되어 널이 아닌 파라미터를 받는다.
fun sendEmailTo(email: String) { /*...*/ }
이 함수에게 널이 될 수 있는 타입을 넘길 수는 없다.
package list6
fun sendEmailTo(email: String) = email
fun main(args: Array<String>){
val email: String? = "secret"
sendEmailTo(email)
}
Type mismatch: inferred type is String? but String was expected
위의 오류를 사전에 예방하기 위해서는 인자를 넘기기 전에 주어진 값이 널인지 검사해야 한다. 아래 코드를 참고해라.
package list6
fun sendEmailTo(email: String) = email
fun main(args: Array<String>){
val email: String? = "secret"
if(email != null) sendEmailTo(email)
}
하지만 let 함수를 통해 인자를 전달할 수도 있다. let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다. 널이 될 수 있는 값에 대해 안전한 구문을 사용해 let을 호출하되 널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달한다.
==> 이렇게 하면 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 된다.
let 함수는 이메일 주소 값이 널이 아닌 경우에만 호출된다. 따라서 다음 예제의 람다안에서는 널이 될 수 없는 타입으로 email을 사용할 수 있다. 아래 코드를 참고하자.
package list6
fun sendEmailTo(email: String) = email
fun main(args: Array<String>){
val email: String? = "secret"
email?.let { email -> sendEmailTo(email) }
}
it을 사용하는 더 짧은 구문을 사용하면 email?.let { sendEmailTo(it) } 처럼 더 짧은 코드도 가능하다.
아래 예제는 let에서 it을 사용하는 예제이다.
package list6
fun sendEmailTo2(email: String) {
println("String email to $email")
}
fun main(args: Array<String>){
val email: String? = "yole@exmple.com"
email?.let { sendEmailTo2(email) }
val email2: String? = null
email2?.let { sendEmailTo2(email2) }
}
String email to yole@exmple.com
위의 코드를 보면 알겠지만, 아주 긴 식이 있고 그 값이 널이 아닐 때 수행해야 하는 로직이 있을 때 let을 사용하면 훨씬 더 편하다.
let을 쓰면 긴 식의 결과를 저장하는 변수를 따로 만들 필요가 없다.
다음 명시적인 if 검사가 있다고 하자.
val person: Person? = getTheBestPersonInTheWorld()
if (person != null) sendEmailTo(person.email)
굳이 person 변수를 추가할 필요 없이 아래와 같이 쓸 수 있다.
package list6
fun sendEmailTo2(email: String) {
println("String email to $email")
}
class emails(val email: String) {
}
fun getTheBestPersonInTheWorld(): emails? = emails("ex@email.com")
fun main(args: Array<String>){
getTheBestPersonInTheWorld()?.let { sendEmailTo2(it.email) }
}
String email to ex@email.com
아래의 코드는 getTheBestPersonInTheWorld() 함수는 null을 반환한다. 따라서 위의 람다식은 결코 실행되지 않는다.
package list6
fun sendEmailTo2(email: String) {
println("String email to $email")
}
class emails(val email: String) {
}
fun getTheBestPersonInTheWorld(): emails? = null
fun main(args: Array<String>){
getTheBestPersonInTheWorld()?.let { sendEmailTo2(it.email) }
}
[ 위의 예제를 간단히 설명하자면, getTheBestPersonInTheWorld()함수로부터 emails 객체타입을 받는다. 여기서 null로 값을 초기화 해줄 수도 있고 emails클래스로부터 객체를 생성해 객체를 넣어줄 수 있다. 여기서 중요한것은 emails의 타입뒤에 ?가 붙어져 널이 가능한 변수(객체)들만 넣을 수 있다는 것이다. 여기서 널이 가능한 변수(객체)들만 넣을 수 있지만 우리가 배운 let{}을 통해 sendEamilTo2()함수에 it.email을 통해 파라미터로 변수를 넣어줬다. 여기서 it.email은 getTheBestPersonInTheWorld()함수가 가지고 있는 email 객체의 email 파라미터이다. ]
여러 값이 널인지 검사해야 한다면 let 호출을 중첩시켜서 처리할 수 있지만 그렇게 let 호출을 중첩시켜 처리하면 코드가 복잡해져서 알아보기 어려워져 일반적인 if를 사용해 모든 값을 한꺼번에 검사하는 편이 낫다.
■ 1-8. 나중에 초기화할 프로퍼티
자주 발생하는 다른 상황으로, 실제로는 널이 될 수 없는 프로퍼티인데 생성자 안에서 널이 아닌 값으로 초기화할 방법이 없는 경우가 있다.
코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화 하지 않고 특별한 메소드 안에서 초기화할 수는 없다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 한다. 그런 초기화 값(널이 아닌 값)을 제공 할 수 없으면 널이 될 수 있는 타입을 사용할 수 밖에 없다.
==> 하지만 널이 될 수 있는 타입을 사용하여 생성자 안에서 초기화 하지 않고 특별한 메소드 안에서 초기화 할 수 있다. 하지만 모든 프로퍼티 접근에 널 검사를 넣거나 !! 연산자를 써야한다.
[EX] - 널 아님 단언을 사용해 널이 될 수 있는 프로퍼티 접근하기
package list6
class MyService {
fun performAction(): String = "foo"
}
class MyTest{
private var myService: MyService? = null //null로 초기화하기 위해서 널이 될 수 있는 타입(?)인 프로퍼티를 선언했다.
@Before fun SetUp() {
myService = MyService() //setUp 메소드 안에서 진짜 초깃값을 지정한다.
}
@Test fun testAction() {
Assert.assertEquals("foo",
myService!!.performAction()) //반드시 널 가능성에 신경써야하므로 !!나 ?을 꼭 써야 한다.
}
}
annotation class Before
위의 코드는 보기 나쁘다. 특히 프로퍼티를 여러 번 사용해야 하면 코드가 더 보기 나쁘다.
==> 이것을 해결하기 위해 myService 프로퍼티를 나중에 초기화할 수 있다. lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화 할 수 있다.
[EX] - 나중에 초기화하는 프로퍼티 사용하기
package list6
class MyService2{
fun performAction(): String = "foo"
}
class MyTest2 {
private lateinit var myService2: MyService2 //초기화하지 않고 널이 될 수 없는 프로퍼티를 선언한다.
@Before fun setUp(){
myService2 = MyService2() //setUp 메소드에서 프로퍼티를 초기화 한다.
}
@Test fun testAction() {
Assert.assertEquals("foo",myService2.performAction())//널 검사를 수행하지 않고 프로퍼티를 사용한다.
}
}
나중에 초기화하는 프로퍼티는 항상 var여야 한다. val 프로퍼티는 final 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야한다.
==> 따라서 생성자 밖에서 초기화해야 하는 나중에 초기하하는 프로퍼티는 항상 var여야 한다. 그렇지만 나중에 초기화하는 프로퍼티는 널이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화 할 필요가 없다. 그 프로퍼티를 초기화하기 전에 프로퍼티에 접근하면 "lateinit property myService has not been initialized[myService라는 lateinit 프로퍼티를 아직 초기화하지 않았음]"이라는 예외가 발생한다." 예외를 보게 되면 구체적인 이유를 적어놓았기 때문에 NulPointerException이 발생하는 것보다 훨씬 좋다.
[ 쉽게 말하면, 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화 하지 않으면 사용할 수 없다. 그래서 널이 가능한 타입으로 null로 초기화를 하고 그것을 다른 메소드들을 통해 초기화를 한다.
하지만, lateinit 프로퍼티를 선언해줌으로써 널이 될 수 없는 프로퍼티를 이용하지만 아무런 값을 초기화 해주지 않고 써도 된다. 추후에 이 프로퍼티를 초기화하기 전에 프로퍼티에 접근하면 lateinit 프로퍼티를 통해 아직 초기화하지 않았다는 오류가 발생하는데 이것은 구체적이기 때문에, 추상적인 NullPointerException이 발생하는 것보다 훨씬 알차다. ]
■ 1-9. 널이 될 수 있는 타입 확장
널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 효율적으로 활용할 수 있다.
==> 어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메소드를 호출해도 확장 함수인 메소드가 알아서 널을 처리해준다. 이런 처리는 확장에서만 가능하다.
String? 타입의 수신 객체에 대해 호출할 수 있는 isNullOrEmpty이나 isNullOrBlank 메소드가 있다. 각각 예제를 통해 알아보자.
[EX] - null이 될 수 있는 수신 객체에 대해 확장 함수 호출하기
package list6
fun verifyUserInput(input: String?) {
if(input.isNullOrBlank()) { //안전한 호출을 하지 않아도 된다.
println("Please fill in the required fileds")
}
}
fun main(args: Array<String>){
verifyUserInput(" ")
verifyUserInput(null) //isNullOrBlank에 "null"을 수신 객체로 전달해도 아무런 예외가 발생하지 않는다.
}
Please fill in the required fileds
Please fill in the required fileds
위의 코드를 보자.
안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언한 확장 함수를 호출 가능하다. 즉, isNullOrBlank()함수는 null 값이 들어오는 경우 이를 적절하게 처리한다.
isNullOrBlank()함수는 널을 명시적으로 검사해서 널인 경우 trun를 반환하고, 널이 아닌 경우 isBlank를 호출한다. isBlank는 널이 아닌 문자열 타입의 값에 대해서만 호출할 수 있다.
fun String?.isNullOrBlank(): Boolean = //널이 될 수 있는 String의 확장
this == null || this.isBlank() //두 번째 "this"에는 스마트 캐스트가 적용된다.
널이 될 수 있는 타입(?)에 대한 확장을 정의하면 널이 될 수 있는 값에 대해 그 확장 함수를 호출할 수 있다. 그 함수의 내부에서 this는 널이 될 수 있다.
==> 따라서 명시적으로 널 여부를 검사해야 한다. 자바에서는 this는 메소드가 호출된 수신 객체를 가리키므로 항상 널이 아니다.
하지만 코틀린에서는 널이 될 수 있는 확장 함수 안에서는 this가 널이 될 수 있다.
앞에서 본 let()함수도 널이 될 수 있는 타입의 값에 대해 호출할 수 있지만 let은 this가 널인지 검사하지 않는다. 널이 될 수 있는 타입의 값에 대해 안전한 호출을 사용하지 않고 let을 호출하면 람다의 인자는 널이 될 수 있는 타입으로 추론된다.
var person: Person? = ...
person.let{ sendEmailTo(it) } //안전한 호출을 하지 않았다. 따라서 "it"은 널이 될 수 있는 타입으로 취급된다.
ERROR: Type mismatch: inferred type is Person? but Person was expected
정리해보자면,
코틀린에서 s.isNullOrBlank()처럼 추가 검사 없이 변수를 참조한다고 해서 무조건 s가 널이 될 수 없는 타입이 되는 아니다. isNullOrBlank()가 널이 될 수 있는 타입의 확장 함수라면 s가 널이 될 수 있는 타입일 수도 있다.
■ 1-10. 타입 파라미터의 널 가능성
코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다.
타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입이다. 예제를 보자.
[EX] - 널이 될 수 있는 타입 파라미터 다루기
package list6
fun <T> printHashCode(t: T){
println(t?.hashCode())//"t"가 null이 될 수 있으므로 안전한 호출을 써야만 한다.
}
fun main(args: Array<String>){
printHashCode(null) //"T"의 타입은 "Any?"로 추론된다.
}
null
위의 코드를 보자.
printHashCode 호출에서 타입 파라미터 T에 대해 추론한 타입은 널이 될 수 있는 Any? 타입이다. t 파라미터의 타입 이름 T에는 물음표가 붙어있지 않지만 t는 null을 받을 수 있다.
만약 타입 파라미터가 널이 아님을 확실히 하고 싶으면 널이 될 수 없는 타입 상한을 지정해야 한다. 그렇게 널이 될 수 없는 타입 상한을 지정하게 되면 널이 될 수 있는 값을 거부하게 된다.
[EX] - 타입 파라미터에 대해 널이 될 수 없는 상한을 사용하기
package list6
fun <T:Any> printHashCode2(t: T){
println(t.hashCode())//이제 "T"는 널이 될 수 없는 타입이다.
}
fun main(args: Array<String>){
printHashCode(null) //이 코드는 컴파일 되지 않는다. 널이 될 수 없는 타입의 파라미터에 널을 넘길 수 없다.
printHashCode(42)
}
fun <T:Any> printHashCode2(t: T){ println(t.hashCode())//이제 "T"는 널이 될 수 없는 타입이다.}fun main(args: Array<String>){ printHashCode2(null) //이 코드는 컴파일 되지 않는다. 널이 될 수 없는 타입의 파라미터에 널을 넘길 수 없다.
printHashCode2(42)}
위의 코드를 보자.
타입 파라미터는 널이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름 뒤에 붙여야 한다는 규칙의 유일한 예외다.
■ 1-11. 널 가능성과 자바
지금까지 코틀린에서 널을 다룰 때 활용할 수 있는 도구에 대해 배웠다.
자바 타입 시스템은 널 가능성을 지원하지 않는다. 이러한 자바와 코틀린을 조합하면 어떻게 될까?
==> 자바 코드에도 애노테이션으로 표시된 널 가능성 정보가 있다. 이런 정보가 코드에 있으면 코틀린도 그 정보를 활용한다.
따라서 자바의 @nullable String은 코틀린 쪽에서 볼 때 String?와 같고, 자바의 @NotNull String은 코틀린에서 String과 같다.
코틀린은 JSR-305 표준(javax.annotation 패 키지), 안드로이드(android.support.annotation 패키지), 젯브레인스 도구들이 지원하 는 애노테이션(org.jetbrains.annotations)들은 코틀린이 이해할 수 있는 널 가능성 애노테이션들이다.
널 가능성 애노테이션이 소스코드에 없는 경우, 자바 경우에서는 자바의 타입은 코틀린의 플랫폼 타입이 된다.
■ 1-11-1. 플랫폼 타입
플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말한다. 그 타입을 널이 될 수 있는 타입으로 처리해도 되고 널이 될 수 없는 타입으로 처리해도 된다. 즉, 플랫폼 타입에 대해 수행하는 모든 연산은 자신에게 있다는 뜻이다.
코틀린은 보통 널이 될 수 없는 타입의 값에 대해 널 안정성을 검사하는 연산을 수행하면 경고를 하지만 플랫폼 타입의 값에 대해 널 안전성 검사하는 연산을 하면 아무 경고도 표시하지 않는다.
즉, 어떤 플랫폼 타입의 값이 널이 될 수 있으면 그 값을 사용하기 전에 널인지 검사를하고, 널이 아님을 알고 있다면 널 검사 없이 그 값을 직접 사용해도 된다는 것이다.
자바로 선언된 Person 클래스가 있다고 가정하자.
[EX] - 널 가능성 애노테이션이 없는 자바 클래스
/* 자바 */
public class Person {
private final String name;
public Person (String name) {
this.name = name;
}
public String getName () {
return name;
}
}
위 코드를 보자.
코틀린에서의 getName은 null을 리턴할까 아닐까?
==> 코틀린 컴파일러는 이 경우 String 타입의 널 가능성에 대해 전혀 알지 못한다. 따라서 널 가능성을 직접 처리해야한다.
이 변수가 널이 아님을 확신할 수 있다면 널 검사를 하지 않아도 된다. 하지만 널 검사를 하지 않으면 예외가 발생할 수도 있음을 염두에 둬야 한다.
[EX] - 널 검사 없이 자바 클래스 접근하기
fun yellAt(person: Person) {
println(person.name.toUpperCase() + "!!!") //toUpperCase()의 수신 객체 person.name가 널이어서 예외가 발생한다.
}
fun main(args: Array<String>){
yellAt(Person(null))
}
java.lang.IllegalArgumentExcept丄 on: Parameter specified as non-null is null: method toUpperCase, parameter $receiver
여기서 NullPointerException이 아니라 toUpperCase()가 수신 객체($receiver)로 널을 받을 수 없다는 더 자세한 예외가 발생한다.
실제로 코틀린 컴파일러는 public 가시성인 코틀린 함수의 널이 아닌 타입인 파라미터와 수신 객체에 대해 널 검사를 추가해준다.
==> 따라서 공개 가시성 함수에 널 값을 사용하면 즉시 예외가 발생한다. 이러한 파라미터 값 널 검사는 함수 내부에서 파라미터를 사용하는 시점이 아니라 함수 호출 시점에 이루어진다. 그렇기에 엉뚱한 위치에 예외가 발생하지 않고 가능한 한 빨리 예외가 발생한다.
또한 getName()의 반환 타입을 널이 될 수 있는 타입으로 해석해서 널 안전성 연산을 활용해도 된다.
[EX] - 널 검사를 통해 자바 클래스 접근하기
fun yellAt(person: Person) {
println(person.name ?: "Anyone").toUpperCase() + "!!!")
}
fun main(args: Array<String>){
yellAt(Person(null))
}
ANYONE!!!
이 예제에서는 널 값을 제대로 처리하므로 실행 시점에 예외가 발생하지 않는다.
자바 API를 다룰 때는 조심해야 한다. 왜냐하면 대부분의 라이브러리는 널 관련 애노테이션을 쓰지 않기 때문에 모든 타입을 널이 아닌 것처럼 다루기 쉽지만 그렇게 하면 오류가 발생할 수 있기 때문이다.
==> 오류를 피하기 위해선 자바 메소드의 문서를 자세히 살펴보고 그 메소드가 널을 반환할 지 알아내고 널을 반환하는 메소드에 대한 널 검사를 추가 해야한다.
코틀린은 왜 플랫폼 타입을 도입했을까?
==> 모든 자바 타입을 널이 될 수 있는 타입으로 다루면 더 안전하고 쉽다고 생각이 되어지는데, 널이 될 수 없는 값에 대해서도 불필요한 널 검사가 들어가기 때문에 플랫폼 타입[ 값이 널이 될 수 있으면 그 값을 사용하기 전에 널인지 검사를하고, 널이 아님을 알고 있다면 널 검사 없이 그 값을 직접 사용 ]을 도입하여 해결하였다.
특히 제네릭을 다룰 때 비효율적인다. 예를 들어 모든 자바ArrayList<String>을 코틀린에서는 ArrayList<String?>? 처럼 다루면 이 배열의 원소에 접근할 때마다 널 검사를 수행하거나 안전한 캐스트를 수행해야하는데 널 안전성으로 얻ㅇ는 이익보다 널 검사에 드는 비용이 훨씬 커지기 때문이다. 그렇기에 타입을 제대로 처리할 책임을 부여하는 실용적인 적근 방법(플랫폼 타입)을 택했다.
코틀린에서 플랫폼 타입을 선언할 수는 없다. 자바 코드에서 가져온 타입만 플랫폼 타입이 된다. 하지만 IDE나 컴파일 오류 메세지에서는 플랫폼 타입을 볼 수 있다.
val i: Int = person.name
ERROR: Type mismatch: inferred type is String! but Int was expected
여기서 코틀린 컴파일러가 표시한 String!라는 타입은 자바 코드에서 온 타입이다. 이런 타입 표기는 한번도 본적이 없다. 당연한다.
==> ! 표기는 String! 타입의 널 가능성에 대해 아무 정보도 없다는 뜻이다. 즉, 플랫폼 타입이란 말이다.
플랫폼 타입을 널이 될 수 있는 타입이나 널이 될 수 없는 타입 어느 쪽으로든 사용할 수 있다.
따라서 아래 두 선언은 모두 올바른 선언이다.
val s: String? = person.name // 자바 프로퍼티를 널이 될 수 있는 타입으로 볼 수 있다.
val s1: String = person.name // 또는 자바 프로퍼티를 널이 될 수 없는 타입으로도 볼 수 있다.
메소드를 호출할 때 처럼 변수(프로퍼티)선언도 자바에서 가져올 프로퍼티 널 가능성을 제대로 알고 사용해야 한다.
자바에서 가져온 널 값을 널이 될 수 없는 코틀린 변수에 대입하면 예외가 발생한다.
[ 정리한다면, 자바 타입을 코틀린에 가져와 사용할 때 널 여부의 대한 애노테이션을 명시한 것들은 알아서 코틀린이 해석을 하고,
애노테이션이 없는 경우에는 플랫폼 타입을 변환이 되는데 이때는 코틀린 사용자가 자바에서 온 코드들의 널 여부를 생각하고 널이 될 가능성이 있다면 ?를 붙여 변수(프로퍼티)를 선언하고 널이 될 가능성이 없다면 코틀린에서는 그냥 변수(프로퍼티)를 선언하면 된다.[코틀린의 기본적으로 널값을 허용하지 않기 때문에 그냥 선언만 하면 됨] ]
■ 1-11-2. 상속
코틀린에서 자바 메소드를 오버라이드 할 때 그 메소드의 파라미터와 반환 타입을 널이 될 수 잇는 타입으로 선언할 지 널이 될 수 없는 타입으로 선언할지 결정해야 한다. 예를 들어 자바의 StringProcessor를 살펴보자.
[EX] - String 파라미터가 있는 자바 인터페이스
/* 자바 */
interface StringProcessor {
void process (String value) ;
}
코틀린 컴파일러는 아래 예제와 같은 두 구현을 다 받아들인다.
[EX] - 자바 인터페이스를 여러 다른 널 가능성으로 구현하기
class StringPrinter : StringProcessor {
override fun process(value: String) {
println(value)
}
}
class NullableStringPrinter: StringProcessor {
override fun process(value: String?) {
if (value != null) {
println(value)
}
}
}
자바 클래스나 인터페이스를 코틀린에서 구현할 경우 널 가능성을 제대로 처리하는 일이 중요하다. 구현 메소드를 다른 코틀린 코드가 호출할 수 있으므로 컴파일러는 널이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 단언문을 만들어준다.
자바 코드가 그 메소드에게 널 값을 넘기면 이 단언문이 발동돼 예외가 발생한다.
■ 1-12 널 가능성에 대한 논의 정리
지금까지 널이 될 수 있는 타입과 널이 될 수 없는 타입과 그 의미에 대해 살펴보면서 안전한 호출(?.), 엘비스 연산자(?:), 안전한 캐스트(?:) 등 안전한 연산을 위한 안전 연산자와 안전하지 못한 참조를 위한 연산자인 널 아님 단언(!!)에 대해 배웠다.
또한 라이브러리 함수인 let 함수를 사용해 널 안전성을 검증한 결과를 널이 될 수 없는 타입의 인자를 받는 함수에게 간결한 구문을 사용해 전달할 수 있음과 확장 함수를 통해 널 검 검사를 함수 안으로 옮길 수 있음을 배웠다.
그리고 자바 타입을 코틀린에서 표현할 때 사용하는 애노테이션과 플랫폼 타입에 대해 공부했다.
■ 2. 코틀린의 원시 타입 ■
이번 절에서는 Int, Boolean, Any 등의 원시 타입에 대해 살펴본다.
코틀린은 원시 타입과 래퍼 타입을 구분하지 않는다. 그 이유와 코틀린 내부에서 어떻게 원시 타입에 대한 래핑이 작동하는지 알아본다.
또한 Object, Void 등의 자바 타입과 코틀린 타입 간의 대응 관계에 대해서도 살펴본다.
■ 2-1. 원시타입: Int, Boolean 등
자바는 원시 타입과 참조 타입을 구분한다.
원시 타입의 변수에는 그 값이 직접 들어가지만, 참조 타입의 변수에는 메모리상의 객체 위치가 들어간다.
자바는 원시 타입에 대해 메소드를 호출하거나 컬렉션에 원시 타입 값을 담을 수 없다. 그렇기에 원시 타입을 참조 타입으로 바꿔야한다.
==> 자바는 참조 타입이 필요한 경우 특별한 래퍼 타입(java.lang.Integer 등)으로 원시 타입 값을 감싸서 사용한다. 따라서 정수의 컬렉션을 정의하려면 Collection<int>가 아니라 Collection<Integer>를 사용해야한다.
코틀린은 원시 타입과 래퍼 타입을 구분하지 않으므로 항상 같은 타입을 사용한다.
val i: Int = 1
val list: List<Int> = listOf(1,2,3)
코틀린처럼 래퍼 타입을 따로 구분하지 않으면 편리하다. 더 나아가 코틀린에서는 숫자 타입 등 원시 타입의 값에 대해 메소드를 호출할 수 있다. 예를들어, 표준 라이브러리 함수 coerceIn을 사용해 값을 특정 범위로 제한할 수 있다. 아래 예제를 보자.
package list6
fun showProgress(progress: Int) {
val percent = progress.coerceIn(0, 100)
println("we're ${percent}% done!")
}
fun main(args: Array<String>){
showProgress(30)
showProgress(146)
}
we're 30% done!
we're 100% done!
원시 타입과 참조 타입이 같다면 코틀린이 그들을 항상 객체로 표현하는 걸까?
==> 코틀린은 항상 객체로 표현하지 않는다.
실행 시점에 숫자 타입은 가능한 한 가장 효율적인 방식으로 표현된다.
==> 대부분의 경우(변수,프로퍼티,반환 타입 등) 코틀린의 Int 타입은 자바의 int 타입으로 컴파일 된다. 이런 컴파일이 불가능한경우는 컬렉션과 같은 제네릭 클래스를 사용하는 경우뿐이다. 예를 들어 Int 타입을 컬렉션의 타입 파라미터로 넘기면 그 컬렉션에는 Int의 래퍼 타입에 해당하는 java.lang.Integer 객체가 들어간다.
Int와 같은 코틀린 타입에는 널 참조가 들어갈 수 없기 때문에 쉽게 그에 상응하는 자바 원시 타입으로 컴파일 할 수 있다. 마찬가지로 반대로 자바 원시 타입의 값은 결코 널이 될 수 없으므로 자바 원시 타입을 코틀린에서 사용할 때도 플랫폼 타입이 아니라 널이 될 수 없는 타입으로 취급할 수 있다.
■ 2-2. 널이 될 수 있는 원시 타입: Int?, Boolean? 등
null 참조를 자바의 참조 타입의 변수에만 대입할 수 있기 때문에 널이 될 수 있는 코틀린 타입은 자바 원시 타입으로 표현할 수 없다.
==> 따라서 코틀린에서 널이 될 수 있는 원시 타입을 사용하면 그 타입은 자바의 래퍼 타입으로 컴파일된다.
[ 정리하자면, 코틀린에서 널이 될 수 없는 타입은 원시 타입을 사용하고 널이 될 수 있는 타입은 래퍼 타입을 사용한다. ]
널이 될 수 있는 타입을 사용하는 예를보자.
앞에서 살펴본 Person 클래스에는 age와 name 프로퍼티 둘 다 모두 널이 될 수 있는 타입이다.
[EX] - 널이 될 수 있는 원시 타입
package list6
data class Person6(val name: String,
val age: Int? = null) {
fun isOlderThan(other: Person6): Boolean? {
if (age == null || other.age == null) {
return null
}
return age > other.age
}
}
fun main(args: Array<String>){
println(Person6 ("Sam", 35).isOlderThan (Person6 ("Amy", 42)))
println(Person6 ("Sam", 35).isOlderThan (Person6 ("Amy")))
}
false
null
위 코드를 보자.
널 가능성 관련 규칙을 보게되면, 널이 될 가능성이 있으므로 Int? 타입의 두 값을 직접 비교할 수는 없다. 그래서 한 방법은 일단 먼저 두 값이 모두 널인지 검사를 하고, 그 후 두 값을 일반적인 값처럼 다루게 허용한다. 그렇지 않으면 코틀린 컴파일 오류가 뜨게 된다.
Person 클래스에 선언된 age 프로퍼티의 값은 java.lang.Integer로 저장된다. 하지만 이런 사항은 자바에서 가져온 클래스를 다룰 때문 문제가 된다. 코틀린에서 적절한 타입을 찾기 위해선 그 변수나 프로퍼티에 널이 들어갈 수 있는지가 중요하고 그것만 고민하면 된다.
제네릭 클래스의 경우 래퍼 타입을 사용한다. 즉, 어떤 클래스의 타입 인자로 원시 타입을 넘기면 코틀린은 그 타입에 대한 박스 타입을 사용한다.
예를들어, 아래에서 null 값이나 널이 될 수 있는 타입을 전혀 사용하지 않았지만[널 이 될 수 있는 타입은 래퍼 타입을 사용함] 만들어지는 리스트 래퍼인 Integer 타입으로 이뤄진 리스트다.
val listOfInts = listOf(1,2,3)
제네릭 클래스에서 래퍼 타입을 사용하는 이유는 JVM(자바 가상머신)에서 정했기 때문이다. JVM은 타입 인자로 원시 타입을 허용하지 않는다.
==> 따라서 자바나 코틀린 모두에서 제네릭 클래스는 항상 박스 타입을 사용해야 한다.
[ 원시 타입으로 이뤄진 대규모 컬렉션을 효율적으로 저장해야 한다면 원시 타입으로 이뤄진 효율적인 컬렉션을 제공하는 서드파티 라이브러리를 사용하거나 배열을 사용해야 한다. ]
■ 2-3. 숫자 변환
코틀린과 자바의 가장 큰 차이점 중 하나는 숫자를 변환하는 방식이다.
코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다. 결코 타입이 허용하는 숫자의 범위가 원래 타입의 범위보다 큰 경우조차도 자동 변환이 불가능하다.
val i = 1
val l: Long = i // "Error: type mismatch" 컴파일 오류 발생
Type mismatch: inferred type is Int but Long was expected
위의 코드는 숫자 변환이 되지 않는다. 변환을 하기 위해서 직접 변환 메소드를 호출해야 한다.
val i = 1
val l: Long = i.toLong()
코틀린은 모든 원시 타입(Boolean 제외)에 대한 변환 함수를 제공한다. [ 변환 함수의 이름은 toByte(), toShort() 등과 같다. ]
양뱡향 변환 함수가 모두 제공한다.
==> 즉, 어떤 타입의 더 표현 범위가 넓은 타입으로 변환하는 함수도 있고(Int.toLong()), 타입을 범위가 더 표현 범위가 좁은 타입으로 변환하면서 값을 벗어나는 경우에는 일부를 잘라내는 함수(Long.toInt()도 있다.
코틀린은 개발자의 혼란을 피하기 위해 타입 변환을 명시하기로 했다. 특히 박스 타입을 비교하는 경우 문제가 많다. 두 박스 타입 간의 eqauls 메소드는 그 안에 들어있는 값이 아니라 박스 타입 객체를 비교한다. [ heap 영역에 있는 객체 비교를 하기 때문에 ]
==> 따라서 자바에서 new Integer(42).eqauls(newLong(42))는 false다. [ 자바에서 new를 이용하면 heap 영역에 새로운 객체를 생성한다. 아무리 42의 같은 값을 가지더라도 new를 통해 heap 영역에서 각각의 영역을 할당받은 객체여서 서로 둘 다 다른 false가 나오게 된다. ]
코틀린에서 묵시적 변환을 허용한다면 다음과 같이 쓸 수 있을 것이다.
val x = 1 //Int 타입인 변수
val list = listOf(1L,2L,3L) //Long 값으로 이뤄진 리스트
x in List //묵시적 타입 변환으로 인해 false임
예상했던 결과와 달리 false다. 따라서 코틀린에서는 타입을 명시적으로 변환해서 같은 타입의 값으로 만든 후 비교해야 한다.
val x = 1
println(x.toLong() in listOf(1L, 2L, 3L)
true
코드에서 동시에 여러 숫자 타입을 사용하려면 예상치 못한 동작을 피하기 위해 각 변수를 명시적으로 변환해야 한다.
또한 숫자 리터럴을 사용할 때 보통 변환 함수를 호출할 필요가 없다. 예를 들어 42L이나 42.f 처럼 상수 뒤에 타입을 표현하는 문자를 붙이 변환이 필요없다.
또한 산술 연산자(+, -, *, /, %)는 적당한 타입의 값을 받아들일 수 있게 이미 오버로드 돼 있다. 아래의 코드를 참고하자.
package list6
fun foo(l: Long) = println(l)
fun main(args: Array<String>){
val b: Byte = 1 //상수 값은 적절한 타입으로 해석된다.
val l = b + 1L // +는 Byte와 Long을 인자로 받을 수 있다.
foo(42) // 컴파일러는 42를 Long 값으로 해석한다.
}
코틀린 산술연산자에서도 자바와 똑같이 숫자 연산 시 오버플로우가 발생할 수 있다.
코틀린은 오버플로우를 검사하느라 추가 비용을 들이지 않는다.
다른 타입을 다루기 전에 특별한 타입인 Any, Unit, Noting을 살펴보자.
■ 2-4. Anym ,Any?: 최상위 타입
자바에서 Object가 클래스 계층의 최상위 타입이듯 코틀린에서는 Any 타입이 모든 널이 될 수 없는 타입의 조상 타입이다.
하지만 자바에서는 참조 타입만 Object를 정점으로 하는 타입 계층에 포함되며, 원시 타입은 그런 계층에 들어있지 않다.
==> 이 말은 즉슨, 자바에서 Object 타입의 객체가 필요한 경우 int와 같은 원시 타입을 java.lang.Integer 같은 래퍼 타입으로 감싸야만 한다는 뜻이다.
하지만 코틀린에서는 Any가 Int 등의 원시 타입을 포함한 모든 타입의 조상 타입이다.
자바와 마찬가지로 코틀린에서도 원시 타입 값을 Any 타입의 변수에 대입하면 자동으로 값을 객체로 감싼다.
val answer: Any = 42 // Any가 참조 타입이기 때문에 42가 박싱된다.
Any가 널이 될 수 없는 타입인 것을 유의해라.
==> 따라서 Any 타입의 변수에는 null이 들어갈 수 없다. 코틀린에서 널을 포함하는 모든 값을 대입할 변수를 선언하려면 Any? 타입을 사용해야한다.
내부에서 Any 타입은 java.lang.Object에 대응한다. 자바 메소드에서 Object를 인자로 받거나 반환하면 코틀린에서는 Any로 그 타입을 취급한다. [ 정확히 말하면 널이 될 수 있는 지 여부를 모르기 때문에 플랫폼 타입인 Any!로 취급한다. ] 코틀린 함수가 Any를 사용하면 자바 바이트코드의 Object로 컴파일된다.
코틀린 클래스에는 toString, equals, hashCode라는 세 메소드가 들어 있는데, 이 세 메소드는 Any에 정의된 메소드를 상속한 것이다.
하지만 java.lang.Object에 있는 다른 메소드(wait나 notify등)은 Any에서 사용할 수 없다.
==> 따라서 그런 메소드(wait,notify)를 호출하고 싶다면 java.lang.Object 타입으로 값을 캐스트해야 한다.
■ 2-5. Unit 타입: 코틀린의 void
코틀린 Unit 타입은 자바 void와 같은 기능을 한다.
fun f(): Unit {...}
위의 코드는 반환 타입 선언 없이 정의한 블록이 본문인 함수와 같다.
fun f() {...} // 반환 타입을 명시하지 않았다.
대부분의 경우 void와 Unit의 차이를 알기는 어렵다. 코틀린 함수의 반환 타입이 Unit이고 그 함수가 제네릭 함수를 오버라이드하지 않는다면 그 함수는 내부에서 자바 void 함수로 컴파일 된다. 그런 코틀린 함수를 자바에서 오버라이드하는 경우 void를 반환 타입으로 해야 한다.
그렇다면 코틀린의 Unit이 자바 void와 다른 점은 무엇일까?
==> Unit은 모든 기능을 갖는 일반적인 타입이며, void와 달리 Unit을 타입 인자로 쓸 수 있다. Unit 타입에 속한 값은 단 하나 뿐이며, 그 이름도 Unit이다. Unit 타입의 함수는 Unit 값을 묵시적으로 반환한다. 이 두 특성은 제네릭 파라미터를 반환하는 함수를 오버라이드 하면서 반환 타입으로 Unit을 쓸 때 유용하다.
package list6
interface Processor<T> {
fun process(): T
}
class NoResultProcessor : Processor<Unit> {
override fun process() { //Unit을 반환하지만 타입을 지정할 필요는 없다.
//업무 처리 코드
} //여기서 return을 명시 할 필요가 없다.
}
인터페이스의 시그니처는 process 함수가 어떤 값을 반환하라고 요구한다. Unit 타입도 Unit 값을 제공하기 때문에 메소드에서 Unit 값을 반환하는 데는 아무 문제가 없다. 하지만 NoResultProcessor에서 명시적으로 Unit을 반환할 필요는 없다. 컴파일러가 묵시적으로 retrun Unit을 넣어준다.
타입 인자로 "값 없음"을 표현하는 문제를 자바에서 어떻게 코틀린과 같이 깔끔하게 해결할 수 있을지 생각해보자.
==> 첫번째 방법으로는 별도의 인터페이스(Callable과 Runnalbe 등과 비슷하게)를 사용해 값을 반환하는 경우와 값을 반환하지 않는 경우를 분리하는 방법도 있다.
다른 방법으로는 타입 파라미터를 특별히 java.lang.void 타입을 사용하는 방법도 있다. 이 방법을 택한다 해도 여전히 Void 타입에 대응할 수 있는 유일한 값인 nul을 반환하기 위해 return null을 명시해야 한다. 이러한 경우 변환 타입이 void가 아니므로 함수를 반환할 때 return을 사용할수 없고 항상 return null을 사용해야 한다.
왜 코틀린에서 Void가 아니라 Unit이라는 다른 이름을 골랐을까?
==> 함수형 프로그래밍에서 전통적으로 Unit은 '단 하나의 인스턴스만 갖는 타입'을 의미해 왔고 바로 그 유일한 인스턴스의 유무가 void와 코틀린 Unit을 구분하는 가장 큰 차이 이기 때문에 Unit을 택했다.
■ 2-6. Noting 타입: 이 함수는 결코 정상적으로 끝나지 않는다.
코틀린에는 결코 성공적으로 값을 돌려주는 일이 없으므로 '반환 값'이라는 개념 자체가 의미 없는 함수가 일부 존재한다.
예를들어, 테스트 라이브러리들은 fail이라는 함수를 제공하는 경우가 많은데, fail은 특별한 메세지가 들어있는 예외를 던져서 현재 테스트를 실패시킨다. 다른 예로, 무한 루프를 도는 함수도 결코 값을 반환하며 정상적으로 끝나지 않는다.
그런 함수를 호출하는 코드를 분석하는 경우 함수가 정상적으로 끝나지 않는다는 사실을 알면 유용해다.
==> 이런 경우를 표현하기 위해서 코틀린에는 Nothing이라는 특별한 반환 타입이 있다.
fun fail(message: String): Nothing {
throw IllegalStateException(message)
}
fun main(args: Array<String>){
fail("Error occurred")
}
java.lang.IllegalStateException: Error occurred
위 코드를 보자.
Nothing 타입은 아무 값도 포함하지 않는다. 따라서 Nothing은 함수 반환 타입이나 반환 타입으로 쓰일 파라미터로만 쓸 수 있다. 그 외의다른 용도로 사용하는 경우 Nothing 타입의 변수를 선언하더라도 그 변수에 아무 값도 저장할 수 없으므로 의미도 없다.
Noting을 반환하는 함수를 엘비스 연산자의 우항에 사용해서 전제 조건을 검사할 수 있다.
fun main(args: Array<String>){
var address = conmpany.address ?: fail("No address")
println(address.city)
}
위의 예제는 타입 시스템에서 Noting이 얼마나 유용한지 보여준다.
컴파일러는 Nothing이 반환 타입인 함수가 결코 정상 종료되지 않음을 알고 그 함수를 호출하는 코드를 분석할 때 사용한다.
company.address가 널인 경우 엘비스 연산자의 우항에서 예외가 발생한다는 사실을 파악하고 address의 값이 널이 아님을 추론할 수 있다.
■ 3. 컬렉션과 배열 ■
코틀린 컬렉션이 자바 라이브러리를 바탕으로 만들어졌고, 확장 함수를 통해 기능을 추가한다는 사실을 배웠다.
하지만 코틀린의 컬렉션 지원과 자바와 컬렉션 간의 관계에 대해 배울 것이 남아있다.
컬렉션 타입을 살펴보고 코틀린 컬렉션과 자바 컬렉션의 차이에 대해 알아보자.
■ 3-1. 널 가능성과 컬렉션
타입 인자의 널 가능성은 타입 시스템 일관성을 지키기 위해 필수적으로 고려해야 할 사항이다.
컬렉션 안에 널 값을 넣을 수 있는지 여부는 어떤 변수의 값이 널이 될 수 있는지 여부와 마찬가지로 중요하다.
변수 뒤에 ?를 붙이면 그 변수에 널을 저장할 수 있다는 뜻인 것처럼 타입 인자로 쓰인 타입에도 같은 표시(?)를 사용할 수 있다.
파일의 각 줄을 읽어서 숫자로 변환하기 위해 파싱하는 예제를 보며 타입 인자 안에서 ?가 하는 일을 이해해보자.
[EX] - 널이 될 수 있는 값으로 이뤄진 컬렉션 만들기
package list6
import java.io.BufferedReader
fun readNumbers(reader: BufferedReader): List<Int?> {
val result = ArrayList<Int?>() //널이 될 수 있는 Int 값으로 이뤄진 리스트를 만든다.
for(line in reader.lineSequence()){
try{
val number = line.toInt()
result.add(number)//정수(널이 아닌 값)를 리스트에 추가한다.
}
catch (e: NumberFormatException){
result.add(null) //현재 줄을 파싱할 수 없으므로 리스트에 널을 추가한다.
}
}
return result
}
List<Int?>는 Int? 타입의 값을 저장할 수 있다.[ 다른 말로 하면 그 리스트에 Int나 null을 저장할 수 있다. ]
현재 줄을 파싱할 수 있으면 result에 정수를 넣고 그렇지 않으면 null을 넣는다. [코틀린 1.1부터는 파싱에 실패하면 null을 반환하는 String.toIntOrNull을 사용해 이 예제를 더 줄일 수 있다. ]
어떤 변수 타입의 널 가능성과 타입 파라미터로 쓰이는 타입의 널 가능성 사이의 차이를 살펴보자.
아래의 그림은 널이 될 수 있는 Int로 이뤄진 리스트(리스트 안의 각 값이 널이 될 수 있음)와 Int로 이뤄진 널이 될 수 있는 리스트(전체 리스트가 널이 될 수 있다)사이의 차이를 아래 그림으로 확인하자.
첫번째 경우 리스트 자체는 항상 널이 아니다. 하지만 리스트에 들어있는 각 원소는 널이 될 수 있다.
두번째 경우 리스트를 가리키는 변수에는 널이 들어갈 수 있지만 리스트 안에는 널이 아닌 값만 들어간다.
경우에 따라 널이 될 수 있는 값으로 이뤄진 널이 될 수 있는 리스트를 정의해야 할 수도 있다.
==> 코틀린에서는 물음표 2개를 사용해 List<Int?>?로 이를 표현한다. 이런 리스트를 처리할 때는 변수에 대해 널 검사를 수행한 다음에 그 리스트에 속한 모든 원소에 대해 다시 널 검사를 수행해야 한다.
정상적인 숫자르 따로 모으고 그렇지 않은 숫자(null)의 개수를 세는 함수를 작성해 널이 될 수 있는 값으로 이뤄진 리스트를 다루는 예를 보자.
[EX] - 널이 될 수 있는 값으로 이뤄진 컬렉션 다루기
package list6
import java.io.BufferedReader
import java.io.StringReader
fun addValidNumbers(numbers: List<Int?>){
var SumOfValidNubers = 0
var invalidNumbers = 0
for(number in numbers) { //리스트에서 널이 될 수 있는 값을 읽는다.
if(number != null){ //널에 대한 값을 확인한다.
SumOfValidNubers += number
}
else{
invalidNumbers++
}
}
println("Sum of valid numbers: $SumOfValidNubers")
println("Invalid numbers : $invalidNumbers")
}
fun main(args: Array<String>){
val reader = BufferedReader(StringReader("1\nabc\n42"))
val numbers = readNumbers(reader)
addValidNumbers(numbers)
}
Sum of valid numbers: 43
Invalid numbers : 1
특별한 내용은 없다. 리스트의 원소에 접근하면 Int? 타입의 값을 얻는다.
==> 따라서 그 값을 산술식에 사용하기 전에 널 여부를 검사해야 한다.
널이 될 수 있는 값으로 이뤄진 컬렉션으로 널 값을 걸러내는 경우가 자주 있어서 코틀린 표준 라이브러리는 그런 일을 하는 filterNotNull이라는 함수를 제공한다. 아래 예제를 보자.
[EX] - filterNotNull를 널이 될 수 있는 값으로 이뤄진 컬렉션에 대해 사용하기
package list6
fun addValidNumbers2(numbers: List<Int?>){
val validNumbers= numbers.filterNotNull()
println("Sum of valid numbers: ${validNumbers.sum()}")
println("Invalid numbers: ${numbers.size - validNumbers}")
}
걸러내는 연산도 컬렉션의 타입에 영향을 끼친다. filterNotNull이 컬렉션 안에 널이 들어있지 않음을 보장해주므로 validNumbers는 List<Int> 타입이다.
정리하자면, 이 절은 코틀린에서 널이 될 수 있는 타입의 값으로 이뤄진 컬렉션에서 널 값과 아닌 값을 분류하는 방법[ filterNotNull() 함수) ]을 배웠다.
■ 3-2. 읽기 전용과 변경 가능한 컬렉션
코틀린 컬렉션과 자바 컬렉션을 나누는 가장 중요한 특성 하나는 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다는 점이다. 이런 구분은 컬렉션을 다룰 때 사용하는 가장 기초적인 인터페이스인 kotlin.colections.Collection부터 시작한다.
이 Collection 인터페이스를 사용하면 컬렉션 안의 원소에 대해 이터레이션 하고, 컬렉션의 크기를 얻고 어떤 컬렉션 안에 들어있는지 검사하고, 컬렉션에서 데이터를 읽는 여러 다른 연산을 수행할 수 있다. 하지만 Collection에는 원소를 추가하거나 제거하는 메소드가 없다.
컬렉션의 데이터를 수정하려면 kotiln.collections.MutableCollection 인터페이스를 사용하라.
MutableCollection은 일반 인터페이스인 kotlin.colections.Collection을 확장하면서 원소를 추가하거나, 삭제하거나, 컬렉션 안의 원소를 모두 지우는 등의 메소드를 더 제공한다.
아래 그림은 두 인터페이스에 들어잇는 핵심 메소드를 보여준다.
코드에서 가능하면 항상 읽기 전용 인터페이스를 사용하는 것을 일반적인 규칙으로 삼고 코드가 변경할 필요가 있을때만 변경 가능한 버전을 사용해라.
val과 var의 구별과 마찬가지로 컬렉션의 읽기 전용 인터페이스와 변경 가능 인터페이스를 구별한 이유는 프로그램에서 데이터에 어떤 일이 벌어지는지를 더 쉽게 이해하기 위함이다. 어떤 함수가 MutableCollection이 아닌 Collection 타입의 인자를 받는다면 그 함수는 컬렉션을 변경하지 않고 읽기만 한다. 반면 어떤 함수가 Mutable Collection을 인자로 받는다면 그 함수가 컬렉션의 데이터를 바꾸리라 가정할 수 있다.
함수 컬렉션은 변경하지 않지만 target 컬렉션을 변경하는 예제를 보자.
[EX] - 읽기 전용과 변경 가능한 컬렉션 인터페이스
package list6
import java.io.BufferedReader
import java.io.StringReader
fun <T> copyElemets(source: Collection<T>, target: MutableCollection<T>) {
for(item in source){ //source 컬렉션의 모든 원소에 대해 루프를 돈다.
target.add(item) // 변경 가능한 target 컬렉션에 원소를 추가한다.
}
}
fun main(args: Array<String>) {
val source: Collection<Int> = arrayListOf(3, 5, 7)
val target: MutableCollection<Int> = arrayListOf(1)
copyElemets(source, target)
println(target)
}
[1, 3, 5, 7]
target(변경 가능한 컬렉션)에 해당하는 인자로 읽기 전용 컬렉션을 넘길 수 없다. 실제 그 값(컬렉션)이 변경 가능한 컬렉션인지 여부와 관계없이 선언된 타입이 읽기 전용이라면 target에 넘기면 넘기면 컴파일 오류가 난다. 아래를 참고해라.
fun main(args: Array<String>) {
val source: Collection<Int> = arrayListOf(3, 5, 7)
val target: Collection<Int> = arrayListOf(1)
copyElemets(source, target)
}
Type mismatch: inferred type is Collection<Int> but MutableCollection<TypeVariable(T)> was expected
컬렉션 인터페이스를 사용할 때 항상 염두에 둬야 할 핵심은 읽기 전용 컬렉션이라고 해서 꼭 변경 불가능한 컬렉션 일 필요는 없다는 점이다. 읽기 전용 인터페이스 타입인 변수를 사용할 때 그 인터페이스는 실제로 어떤 컬렉션 인스턴스를 가리키는 수많은 참조 중 하나일 수 있다. 아래 그림과 같이 같은 인스턴스를 가리키는 변경 가능한 인터페이스 타입의 참조도 있을 수 있다.
어떤 동일한 컬렉션 객체를 가리키는 읽기 전용 컬렉션 타입의 참조와 변경 가능한 컬렉션 타입의 참조가 있는 경우에서 이 컬렉션을 참조하는 다른 코드를 호출하거나 병렬 실행한다면 컬렉션을 사용하는 도중에 다른 컬렉션이 그 컬렉션의 내용을 변경하는 상황이 생길 수 있는데, 이 때 ConcurrentModificationException나 다른 오류가 발생 할 수 있다.
==> 따라서 읽기 전용 컬렉션이 항상 스레드 안전 하지는 않다는 점을 명심해야 한다. 다중 스레드 환경에서 데이터를 다루는 경우 그 데이터를 적절히 동기화하거나 동시 접근을 허용하는 데이터 구조를 활용해야 한다.
■ 3-3. 코틀린 컬렉션과 자바
모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스라는 점은 사실이다.
==> 따라서 코틀린과 자바 사이를 오갈 때 아무 변환도 필요 없다. 하지만 코틀린은 모든 자바 컬렉션 인터페이스마다 읽기 전용 인터페이스와 변경 가능한 인터페이스라는 두가지 표현을 제공한다.
코틀린의 읽기 전용과 변경 가능 인터페이스의 기본 구조는 java.util 패키지에 있는 자바 컬렉션 인터페이스의 구조를 그대로 옮겨 놓았다.
추가로 변경 가능한 각 인터페이스는 자신과 대응하는 읽기 전용 인터페이스를 확장(상속)한다. 변경 가능한 인터페이스는 java.util 패키지에 있는 인터페이스와 직접적으로 연관되지만 읽기 전용 인터페이스에는 컬렉션을 변경할 수 있는 모든 요소가 빠져 있다.
위에 있는 그림을 보게 되면 java.util.ArrayList와 java.util.HashSet 클래스가 들어있다. 코틀린은 이들이 각각 MutabbleList와 MutableSet 인터페이스를 상속한 것처럼 취급한다. 자바 컬렉션 라이브러리에 있는 다른 인터페이스도 동일한 방식으로 Mutable 인터페이스를 상속한 것 처럼 적용한다.
컬렉션과 마찬가지로 Map 클래스(맵은 Collection이나 Iterable을 확장하지 않는다)도 코틀린에서 Map과 MutableMap이라는 두 가지 버전으로 나타난다. 아래의 표는 여러 다른 컬렉션을 만들 때 사용하는 함수를 보여준다.
[ 여담으로 setOf()와 mapOf()는 자바 표준 라이브러리에 속한 클래스의 인스턴스를 반환한다. 그들의 내부에서는 변경 가능한 클래스다. 하지만 그 둘이 변경 가능한 클래스라는 사실에 의존하면 안된다. 미래에 가서는 불변 컬렉션 인스턴스를 반환하게 바뀔 수 도 있기 때문이다.. ]
자바 메소드를 호출하되 컬렉션을 인자로 넘겨야 한다면 따로 변환하거나 복사하는 등의 추가 작업 없이 직접 컬렉션을 넘기면 된다.
예를 들어 java.util.Collection을 파라미터로 받는 자바 메소드가 있다면 아무 Collection이나 MutableCollection 값을 인자로 넘길 수 있다.
==> 이런 성질로 인해 컬렉션의 변경 가능성과 관련해 중요한 문제가 발생한다. 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않으므로, 코틀린에서 읽기 전용 Collection으로 선언된 객체라도 자바 코드에서 그 컬션션 객체의 내용을 변경할 수 있다.
코틀린 컴파일러가 이 부분은 책임져주지 못한다. 그렇기에 컬렉션을 자바로 넘기는 코틀린 프로그램을 작성한다면 호출하려는 자바 코드가 컬렉션을 변경할 지 여부에 따라 올바른 파라미터 타입을 사용할 책임은 자신에게 있다.
위와 같이 코틀린에서 변경 불가 컬렉션을 넘겼는데, 자바는 아무것도 모른채[ 자바는 변경 가능한 컬렉션 밖에 없다. ] 변경 가능한 컬렉션으로 사용하는 경우처럼 함정은 널이 아닌 원소로 이뤄진 컬렉션 타입에서도 발생한다. 즉, 널이 아닌 원소로 이뤄진 컬렉션을 자바 메소드로 넘겼는데[ 자바는 기본적으로 Null을 다 허용한다. ] 자바 메소드가 널을 컬렉션에 넣을 수도 있다. 이 함정들은 코틀린 컴파일러에서도 책임을 지지 않기 때문에 컬렉션을 자바 코드에게 넘길 대는 특별히 주의를 기울여야 한다.
■ 3-4. 컬렉션을 플랫폼 타입으로 다루기
자바 코드에서 정의한 타입을 코틀린에서는 플랫폼 타입[애노테이션이 없을 경우]으로 보는데, 이 경우 코틀린 쪽에는 널 관련 정보가 없다.
==> 따라서 컴파일러는 코틀린 코드가 그 타입을 널이 될 수 있는 타입이나 널이 될 수 없는 타입 어느 쪽으로든 사용할 수 있게 허용한다.
마찬가지로 자바쪽에서 선언한 컬렉션 타입의 변수를 코틀린에서는 플랫폼 타입으로 본다.
플랫폼 타입인 컬렉션은 기본적으로 변경 가능성에 대해 알 수 없다.
==> 따라서 코틀린 코드는 그 타입을 읽기 전용 컬렉션이나 변경 가능한 컬렉션 어느 쪽이로든 다룰 수 있다.
컬렉션 타입이 시그니처에 들어간 자바 메소드 구현을 오버라이드하려는 경우 읽기 전용 컬렉션과 변경 가능 컬렉션의 차이가 문제가 된다.
플랫폼 타입에서 널 가능성을 다룰 때 처럼 이런 경우에도 오버라이드하려는 메소드의 자바 컬렉션 타입을 어떤 코틀린 켈렉션 타입으로 표현할 지 결정해야 한다.
이런 상황에서는 여러 가지를 선택해야 한다. 그리고 이렇게 선택한 내용을 코틀린에서 사용할 컬렉션 타입에 반영해야 한다.
- 컬렉션이 널이 될 수 있는가?
- 컬렉션의 원소가 널이 될 수 있는가?
- 오버라이드하는 메소드가 컬렉션을 변경할 수 있는가?
선택에 따라 차이가 생기는 몇 가지 경우를 예제를 통해 살펴보자.
첫번째 예제에서는 자바 인터페이스가 파일에 들어있는 텍스트를 처리하는 객체를 표현한다.
[EX] - 컬렉션 파라미터가 있는 자바 인터페이스
/* 자바*/
interface FileContentProcessor {
void processContents (File path, byte[] binaryContents, List<String> textContents);
}
위의 인터페이스를 코틀린으로 구현하려면 다음을 선택해야 한다.
- 일부 파일은 이진 파일이며 이진 파일 안의 내용은 텍스트로 표현할 수 없는 경우가 있으므로 리스트는 널이 될 수 있다.
- 파일의 각 줄은 널일 수 없으므로 이 리스트의 원소는 널이 될 수 없다.
- 이 리스트는 파일의 내용을 표현하며 그 내용을 바꿀 필요가 없으므로 읽기 전용이다.
다음은 위에 있는 자바 인터페이스를 코틀린으로 구현한 모습이다.
[EX] - FileContentProcessor를 코틀린으로 구현한 모습
class FileIndex: FileContentProcessor {
override fun processContents(path: File,
binaryContents: ByteArray?,
testContens: List<String>?) {
//...
}
}
이것을 다른 인터페이스와 비교해보자. 여기서 인터페이스를 구현한 클래스가 텍스트 폼에서 읽은 데이터를 파싱해서 객체 리스트를 만들고, 그 리스트의 객체들을 출력 리스트 뒤에 추가하고, 데이터를 파싱하는 과정에서 오류 메시지를 별도의 리스트에 넣어서 오류를 보고한다.
[EX] - 컬렉션 파라미터가 있는 다른 자바 인터페이스
/★ 자바 ★/
interface DataParser<T> {
void parseData (String input,
List<T> output,
List<String> errors); }
위의 예제에서 선택한 내용은 다음과 같다.
- 호출하는 쪽에서 항상 오류 메시지를 받아야 하므로 List<String>은 널이 되면 안된다.
- errors의 원소는 널이 될 수도 있다.[ output에 들어가는 정보를 파싱하는 과정에서 오류가 발생하지 않으면 그 정보와 연관된 오류 메시지는 널이다. ]
- 구현 코드에서 원소를 추가할 수 있어야 하므로 List<String>은 변경 가능해야 한다.
이것을 코틀린으로 구현하면 다음과 같다.
[EX] - DataParser의 코틀린 구현
class PersonParser: DataParser<Person> {
override fun parseData(input: String, output: MutableList<Person>
, errors: MutableList<String?>) {
//...
}
}
자바에서 같았던 타입(List<String>)이 코틀린에서 어떻게 달라졌는지 보아라.
자바 List<String>를 구현하는데 한 구현은 List<Sring>?(문자열이 이뤄진 널이 될 수 있는 읽기 전용 리스트)를 사용하고, 다른 구현은 MutableList<String?>(널이 될 수 있는 문자열로 이뤄진 변경 가능한 리스트)를 사용한다.
==> 이런 선택을 제대로 하려면 자바 인터페이스나 클래스가 어떤 맥락에서 사용되는지 정확히 알아야 코틀린 구현에서 어떤 작업을 수행하야 할 지 검토하면 쉽게 결정을 내릴 수 있다.
■ 3-5. 객체의 배열과 원시 타입의 배열
아래 예제는 코틀린 배열이 어떻게 생겼는지 다시 한번 보여준다.
package list6
fun main(args: Array<String>) {
for (i in args.indices){ // 배열의 인덱스 값의 범위에 대해 이터레이션하기 위해 array.indices 확장 함수를 사용한다.
println("Argument $i is: ${args[i]}") //array[index]로 인덱스를 사용해 배열 원소에 접근한다.
}
}
코틀린 배열은 타입 파라미터를 받는 클래스다. 배열의 원소 타입은 바로 그 타입 파라미터에 의해 정해진다.
코틀린에서 배열을 만드는 방법은 다양하다.
- arrayOf 함수에 원소를 넘기면 배열을 만들 수 있다.
- arrayOfNulls 함수에 정수 값을 인자로 넘기면 모든 원소가 null이고 인자로 넘긴 값과 크기가 같은 배열을 만들 수 있다.
물론 원소 타입이 널이 될 수 있는 타입인 경우에만 이 함수를 쓸 수 있다. - Array 생성자는 배열 크기와 람다를 인자로 받아서 람다를 호출해서 각 배열 원소를 초기화 해준다. arrayOf를 쓰지 않고 각 원소가 널이 아닌 배열을 만들어야 하는 경우 이 생성자를 사용한다.
간단한 예제로 다음은 Array 생성자를 사용해 a부터 z까지 26개의 알파벳 소문자에 해당하는 문자열이 원소인 배열을 만든다.
[EX] - 알파벳으로 이뤄진 배열 만들기
package list6
fun main(args: Array<String>) {
val letters = Array<String>(26) { i-> ('a'+i).toString()}
println(letters.joinToString(""))
}
abcdefghijklmnopqrstuvwxyz
람다는 배열 원소의 인덱스를 인자로 받아서 배열의 해당 위치에 들어갈 원소를 반환한다. 여기서는 인덱스 값에 a문자 값을 더한 결과를 문자열로 변환한다.
코틀린에서는 배열을 인자로 받는 자바 함수를 호출하거나 vararg 파라미터를 받는 코틀린 함수를 호출하기 위해 가장 자주 배열을 만든다.하지만 이때 데이터가 이미 컬렉션에 들어 있다면 컬렉션을 배열로 변환해야 한다. toTypedArray 메소드를 사용하며 쉽게 컬렉션을 배열로 바꿀 수 있다.
[EX] - 컬렉션을 vararg 메소드에게 넘기기
package list6
fun main(args: Array<String>) {
val strings = listOf("a","b","c")
println("%s/%s/%s".format(*strings.toTypedArray()))//varag 인자를 넘기기 위해 스프레드 연산자(*)를 써야 한다.
}
a/b/c
다른 제네릭 타입에서도 배열 타입의 타입 인자도 항상 객체 타입이 된다. 따라서 Array<Int>같은 타입을 선언하면 그 배열을 박싱된 정수의 배열(자바 타입은 java.lang.Integer[])이다. 박싱하지 않은 원시 타입의 배열이 필요하다면 그런 타입을 위한 특별한 배열 클래스를 사용해야 한다.
코틀린은 원시 타입의 배열을 표현하는 별도 클래스를 각 원시 타입마다 하나씩 제공한다.
예를 들어 Int 타입의 배열은 IntArray다. 코틀린은 ByteArray, CharArray, BooleanArray 등의 원시 타입 배열을 제공한다.
이 모든 타입은 자바 원시 타입 배열인 int[], bute[], char[] 등으로 컴파일된다.
==> 따라서 이러한 배열의 값은 박싱하지 않고 가장 효율적인 방식으로 저장된다.
원시 타입의 배열을 만드는 방법은 다음과 같다.
- 각 배열 타입의 생성자는 size 인자를 받아서 해당 원시 타입의 디폴트 값(보통은 0)으로 초기화된 size 크기의 배열을 반환한다.
- 팩토리 함수(IntArray를 생성하는 intArrayOf 등) 여러 값을 가변 인자로 받아서 그런 값이 들어간 배열을 반환한다.
- (일반 배열과 마찬가지로) 크기와 람다를 인자로 받는 생성자를 사용한다.
다음은 첫번째와 두번째 방법으로 5개의 5이 들어잇는 배열을 만드는 코드를 보여준다.
val fiveZeros = IntArray(5)
val fiveZeroToo = intArrayOf(0,0,0,0,0)
다음은 람다를 인자로 받는 생성자를 사용하는 방법이다.
fun main(args: Array<String>) {
val squares = IntArray(5) { i-> (i+1) * (i+1) }
println(squares.joinToString())
}
1, 4, 9, 16, 25
이 밖에 박싱된 값이 들어있는 컬렉션이나 배열이 있다면 toIntArray등의 변환 함수를 사용해 박싱하지 않은 값이 들어있는 배열로 변환할 수 있다.
배열로 할 수 있는 일을 예제로 통해 살펴보자. 코틀린 표준 라이브러리는 배열 기본 연산(배열 길이 구하기, 원소 설정하기, 원소 읽기)에 더해 컬렉션에 사용할 수 있는 모든 확장 함수를 배열에도 제공한다. filter, map 등을 배열에 써도 잘 작동한다. 원시 타입 원소로 이뤄진 배열에도 그런 확장 함수를 똑같이 사용할 수 있다. [ 다만 이런 함수가 반환하는 값은 배열이 아니라 리스트라는 점을 유의해라. ]
forEachIndexed 함수와 람다를 사용해보자.
foEachIndexed() 함수는 배열의 모든 원소를 갖고 인자로 받은 람다를 호출해준다. 이 때 배열의 원소와 그 원소의 인덱스를 람다를 인자로 전달한다.
[EX] - 배열에 forEachIndexed 사용하기
fun main(args: Array<String>) {
args.forEachIndexed { index, element -> println("Argument $index is: $element") }
}
■ 4. 정리 ■
- 코틀린은 널이 될 수 있는 타입을 지원해 NullPointerException 오류를 컴파일 시점에 감지할 수 있다.
- 코틀린의 안전한 호출(?.), 엘비스 연산자(?:), 널아님 단언(!!), let 함수 등을 사용하면 널이 될 수 있는 타입을 간결한 코드로 다룰 수 있다.
- as? 연산자를 사용하면 값을 다른 타입으로 변환하는 것과 변환이 불가능한 경우를 처리하는 것을 한꺼번에 편리하게 처리할 수 있다.
- 자바에서 가져온 타입은 코틀린에서 플랫폼 타입으로 취급된다. 개발자는 플랫폼 타입을 널이 될수 있는 타입으로도 널이 될 수 없는 타입으로도 사용할 수 있따.
- 코틀린에서는 수를 표현하는 타입(Int 등)이 일반 클래스와 똑같이 생겻고 일반 클래스와 똑같이 동ㅈ가한다. 하지만 대부분 컴파일러는 숫자 타입을 자바 원시 타입(int 등)으로 컴파일한다.
- 널이 될 수 있는 원시 타입(Int? 등)은 자바의 박싱한 원시 타입(java.lang.Integer등)에 대응한다.
- Any 타입은 다른 모든 타입의 조상 타입이며, 자바의 Object에 해당한다. Unit은 자바의 void와 비슷하다.
- 정상적으로 끝나지 않는 함수의 반환 타입을 지정할 때 Nothing 타입을 사용한다.
- 코틀린 컬렉션은 표준 자바 컬렉션을 사용한다. 하지만 코틀린은 자바보다 컬렉션을 더 개선해서 읽기 전용 컬렉션과 변경 가능한 컬렉션을 구별해 제공한다.
- 자바 클래스를 코틀린에서 확장하거나 자바 인터페이스를 코틀린에서 구현하는 경우 메소드 파라미터의 널 가능성과 변경 가능성에 대해 깊이 생각해야 한다.
- 코틀린의 Array 클래스는 일반 제네릭 클래스처럼 보인다. 하지만 Array는 자바 배열로 컴파일 된다.
- 원시 타입의 배열은 IntArray와 같이 각 타입에 대한 특별한 배열로 표현된다.
'Kotlin > Kotlin in action' 카테고리의 다른 글
5장 람다로 프로그래밍 (1) | 2022.09.29 |
---|---|
4장 클래스, 객체, 인터페이스 (1) | 2022.09.24 |
3장 함수 정의와 호출 (1) | 2022.09.23 |
2장 코틀린 기초 (1) | 2022.09.22 |
1장 코틀린이란 무엇이며, 왜 필요한가? (1) | 2022.09.21 |