Study/다재다능 코틀린 프로그래밍
Day 6. 오류를 예방하는 타입 안정성
주지민
2021. 11. 2. 23:48
반응형
본 포스트는 지인들과 스터디한 내용을 정리한 포스트입니다
http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788931463422
1. 오류를 예방하는 타입 안정성
- 코틀린의 디자인 바이 컨트랙트 접근방식으로 개발자는 함수나 메서드가 null을 받거나 리턴할 수 있는지 명확하게 표현할 수 있으며, 그 시점도 알 수 있다
- 신 기능들은 컴파일 시점에 체크를 하므로 성능에 전혀 영향이 없다
- 코틀린의 모든 클래스는 Java의 Object 클래스처럼 Any 클래스에서 상속을 받는다
- Any 클래스는 코틀린의 모든 클래스에서 사용 가능한 유용한 메서드를 포함하고 있다
2. Any와 Nothing 클래스
- 코틀린의 Any 클래스는 Java의 Object에 대응되는 클래스
- Nothing 클래스는 함수가 아무것도 리턴하지 않을 경우 리턴하는 클래스
2-1. 베이스 클래스 Any
- 코틀린의 모든 클래스는 Any를 상속받는다
- 함수가 여러 타입의 객체를 파라미터로 받는다면, 함수의 파라미터를 Any로 설정해 놓으면 된다
- 이와 유사하게 특정 타입을 리턴하기 어렵다면 Any를 리턴하면 된다
- Any 클래스는 개발자에게 최대한 유연성을 제공하기 때문에, 아주 제한적으로 사용해야만 한다 ( 과한 기능 )
- equals(), hashCode(), toString() 같은 메서드들은 코틀린의 모든 타입에서 사용 가능 ( Any에 구현되어 있기 때문에 )
- Object와 비슷하지만 Any는 더 많은 확장 함수를 가지고 있다 ( let(), run(), apply(), also() 등등 )
2-2. Nothing은 void보다 강력하다
- 코틀린에서는 표현식이 리턴을 하지 않을때 void대신 Unit을 사용한다
- 함수 자체가 정말 아무것도 리턴하지 않을 때 Nothing 클래스를 사용한다
- Nothing 클래스는 인스턴스가 없고 값이나 결과가 영원히 존재하지 않을 것이라는 걸 나타낸다
- 예외는 Nothing 타입을 대표한다 ( 컴파일러가 프로그램의 무결성을 검증하도록 도와주는 역할 )
3. Null 가능 참조
- 함수에서 null을 리턴하게 되면 호출하는 쪽에서 null 체크가 강제된다. 이를 체크안할 경우 NullPointException이 발생
- 최근 Java에서는 Optional을 통해 이슈를 해결해 나가고 있지만 몇가지 불리한 점이 있다
- 개발자가 Optional을 사용해야한다 ( 컴파일러가 Optional을 강제하지 않는다 )
- Optional이 객체의 참조 또는 null을 감쌀 때 객체가 없다면 작은 오버헤드가 생긴다
- 개발자가 Optional이 아닌 null을 리턴해도 컴파일러는 아무 경고를 주지 않는다
- 이를 해결하기 위해 코틀린은 안전하고 편리한 nullable 참조를 디레퍼런스할 수 있게 하는 연산자를 제공
- 일반적으로 코틀린 코드를 작성할 때 Java와 상호운용할 목적이 아니라면 null과 nullable 타입은 절대 사용하지 않는 편이 좋다
- null 불가 타입들은 각자 대응하는 null 가능 타입이 존재한다 ( String의 null 가능 타입은 String? )
3-1. 세이프 콜 연산자
- ?연산자를 이용하면 메소드 호출 또는 객체 속성 접근과 null 체크를 하나로 합칠 수 있다
- ?연산자를 세이프 콜 연산자라고 한다
- 참조가 null일 경우에 세이프 콜 연산자의 결과는 null이다
- 참조가 null이 아닐 경우 연산결과는 속성이거나 메서드의 결과가 된다
- 예제코드
// safecalloperator.kts // not checked null fun example(name: String?): String? { if (null != name) { return name.reversed() } return null } // safe call operator fun example2(name: String?): String? { return name?.reversed()?.uppercase() } println(example(null)) // null println(example2(null)) // null
3-2. 엘비스 연산자
- 세이프 콜 연산자는 타깃이 null일 경우에 null을 리턴한다
- 엘비스 연산자 ?: 을 이용하면 좌측 표현식의 결과가 null이 아닐 경우 결과를 리턴해주고 null일 경우 우측 표현식의 결과를 리턴해준다
// example return name?.reversed()?.upperCase() ?: "Joker"
3-3. 확정 연산자 !!
- not-null 확정 연산자 '!!' , 조커 연산자라고 불리기도 한다
- 사용해서는 안되는 안전하지 않은 연산자이다
- 코틀린에서는 null 가능 타입일 경우 null 체크를 하지 않으면 해당 타입에 대응되는 null 불가 타입의 메서드를 호출할 수 없었다
- 하지만 특정 참조가 절대 null이 아니란 사실을 알고 있다면 코틀린에게 쓸데없는 체크를 하지말라는 키워드를 전달할 수 있는데 이때 사용하는 것이 확정 연산자 '!!'이다
- 당연히 NULL이 반환되면 NullPointException이 나기 때문에 절대 사용하지 말자
// example return name!!.reversed().toUpperCase()
3-4. when의 사용
- null 가능 참조로 작업을 할 때 참조의 값에 따라서 다르게 동작하거나 다른 행동을 취해야 한다면 '?.'(세이프 콜) 이나 '?:'(엘비스) 보다는 when을 사용하는 것을 고려해보자
- 즉 세이프 콜이나 엘비스 연사나는 값을 추출해낼 때 사용하고 when은 null 가능 참조에 대한 처리를 결정해야 할 때 사용하도록 하자
- 예제 코드
// when.kts // 세이프 콜 + 엘비스 조합 fun nickName(name: String?): String { if (name == "William") { return "Bill" } return name?.reversed()?.uppercase() ?: "Joker" } // when fun nickName2(name: String?) = when (name) { "William" -> "Bill" null -> "Joker" else -> name.reversed().uppercase() } println(nickName(null)) // "Joker" println(nickName2(null)) // "Joker"
- when이 표현식으로 쓰였다
- else절이 가장 마지막에 나와야하며 null에 해당하는 절은 else 위면 어디든 상관없다
- null 체크를 했기 떄문에 when에 있는 다른 모든 경우 전달받은 참조가 null이 아닌 경우에만 동작
4. 타입 체크와 캐스팅
- 타입 체크
- 코드의 확정성 측면에서 봤을 때 타입체크는 최소한으로만 해야한다
- 임의의 타입을 체크하는 행위는 새로운 타입이 추가됐을 때 코드를 부서지기 쉽게 만들고 개방-폐쇄 원칙에도 위배된다
- 실행 시간에 반드시 타입체크해야하는 상황
- equals() 메서드를 구현할 때, 현재 가지고 있는 객체가 해당 클래스의 인스턴스인지 알아야하기 때문에
- when을 사용할 때 when의 분기가 인스턴스의 타입에 기반해서 이루어지는 경우 타입체크가 필요하다
- is 사용하기
- Object의 equals() 메서드는 참조 기반 비교이다, 하지만 클래스에서 equals() 메서드를 참조(동등성)이 아닌 동일성을 확인하도록 오버라이드했을 수도 있다
- 코틀린의 Any 클래스(모든 클래스의 부모)의 자식인 코틀린의 클래스는 equals() 메서드를 오버라이드 했다
- 코틀린은 '==' 연산자를 equals() 메서드에 매핑해놨다
- is 연산자는 객체가 참조로 특정 타입을 가리키는지 확인한다 ( instanceof )
- 예제 코드
// equals.kts class Animal { override operator fun equals(other: Any?) = other is Animal } val greet: Any = "hello" val odie: Any = Animal() val toto: Any = Animal() println(odie == greet) // false println(odie == toto) // true
- 참조 타입을 비교하려면 '==='
- is 연산자는 모든 타입의 참조에 사용될 수 있다
- 참조가 null일 경우 is의 연산결과는 false 이다
- is 연산자 뒤의 타입이 객체의 타입과 같거나 상속관계에 있다면 true를 리턴한다
- is 연산자는 '!'키워드와 조합하여 부정을 나타낼 수도 있다
- 스마트 캐스트
- 코틀린은 참조의 타입이 확인되면 자동 혹은 스마트 캐스팅을 한다
- 예제코드
// smartcast.kts class Animal(val age: Int) { override operator fun equals(other: Any?): Boolean { return if (other is Animal) age == other.age else false } } val odie = Animal(2) val toto = Animal(2) val butch = Animal(3) println(odie == toto) // true println(odie == butch) // false
- is 연산자로 체크를 했기 때문에 캐스트를 할 필요가 없다
- 스마트 캐스트는 코틀린이 타입을 확인하는 즉시 작동한다
- if문 뿐만 아니라 '||', '&&' 연산자 이후에도 작동한다
- 위 코드를 이런식으로도 바꿀 수 있다
// smartcase.kts class Animal2(val age: Int) { override operator fun equals(other: Any?) = other is Animal2 && age == other.age } val odie = Animal2(2) val toto = Animal2(2) val butch = Animal2(3) println(odie == toto) // true println(odie == butch) // false
- 코틀린은 스마트 캐스트가 가능하다면 자동으로 스마트 캐스트를 해준다
- 참조의 타입이 특정 타입이란 것이 확인되면 캐스트를 할 필요가 없다 ( ex) 객체가 null 참조가 아니라고 판별하면 null 가능 타입이 null 불가 타입으로 자동으로 캐스팅된다 )
- when과 함께 타입 체크와 스므타 캐스트 사용하기
- when 명령문 또는 표현식에 is나 !is, 스마트 캐스팅을 사용할 수 있다
- 예제코드
// whattodo.kts fun whatToDo(dayOfWeek: Any) = when (dayOfWeek) { "Saturday", "Sunday" -> "Relax" in listOf("Monday", "Tuesday", "Wednesday", "Thursday") -> "Work hard" in 2..4 -> "Work hard" "Friday" -> "Party" is String -> "What?" else -> "No clue" } println(whatToDo("Saturday")) // Relax println(whatToDo("Monday")) // Work hard println(whatToDo(2)) // Work hard println(whatToDo("Friday")) // Party println(whatToDo("hihihihi")) // What?
- when에서 사용되는 조건 중 하나는 실행 중에 전달받은 파라미터가 String인지 확인하기 위해서 is 연산자로 타입 체크를 하는 것
5. 명시적 타입 캐스팅
- 명시적 타입캐스팅은 컴파일러가 타입을 확실하게 결정할 수 없어 스마트 캐스팅을 하지 못할 경우에만 사용하도록 하자
- 예를 들어 var 변수가 체크와 사용 사이에서 변경되었다면 코틀린은 타입을 보장해줄 수 없다
- 이런 케이스는 스마트 캐스팅을 적용할 수 없음
- 코틀린은 명시적 타입 캐스트를 위해서 2가지 연산자를 제공
- as
- 우측에 지정된 타입과 같은 타입을 결과로 준다
- 캐스팅이 실패하면 프로그램을 종료시킨다 ( as로 지정한 타입이 명확해야함 )
- as?
- null 가능 참조 타입을 결과로 가진다
- 캐스팅이 실패하면 null을 반환한다
- as
- 타입 캐스팅 권장사항
- 가능한 스마트 캐스팅을 사용하라
- 스마트 캐스팅이 불가능한 경우에만 안전한 캐스팅 연산자(as?)를 사용하라
6. 제네릭: 파라미터 타입의 가변성과 제약사항
- 코드 재사용에 대한 욕구가 타입 안정성을 저하시켜서는 안된다 => 해결책: 제네릭
- 제네릭을 사용하면 다양한 타입에서 사용 가능한 코드를 만들 수 있다
- 동시에 컴파일러는 제네릭 클래스 또는 함수가 의도하지 않은 타입에서 사용되는지를 검증할 수 있다
- Java에서의 제네릭
- 타입 불변성
- 제네릭 함수가 파라미터 타입 T를 받는다면 T의 부모 클래스나 자식 클래스를 사용하는 것이 불가능
- <? extend T>, <? super T> 를 통해 공변성을 사용하지만, 제네릭을 사용할때만 가능하고 선언할때는 불가능하다 ( ??? 뭔말인지 이해못했다... )
- 타입 불변성
- 메소드가 타입 T의 제네릭 오브젝트를 받는다면, 즉 List<T>, T의 파생 클래스(자식, 부모)를 전달할 수 없다
- 예를 들어 List<Animal>을 전달할 수는 있지만 Dog extends Animal 이더라도 List<Dog>를 전달할 수 없다, 이러한 성격을 타입 불변성이라고 한다
- 상속이란 대체 가능성을 의미
- 자식 클래스의 인스턴스는 부모 클래스의 인스턴스를 인자로 하는 모든 메서드에 전달할 수 있다
- 예제코드
// receiveFruits.kts open class Fruit class Banana: Fruit() class Orange: Fruit() fun receiveFruits(fruites: Array<Fruit>) { println("Number of fruits: ${fruites.size}") } fun receiveFruits2(fruites: List<Fruit>) { println("Number of fruits: ${fruites.size}") } //val bananas: Array<Banana> = arrayOf(); //receiveFruits(bananas) // type mismatch var bananas: List<Banana> = listOf(); receiveFruits2(bananas) // 0 ( Success )
- Array<T>를 파라미터로 받는 메서드에 T의 자식 클래스를 전달하면 "type mismatch" 오류가 발생했다, Array<T>는 뮤터블이기 때문
- List<T>를 파라미터로 받는 메서드에는 T의 자식 클래스가 그대로 전달됐다. List<T>는 이뮤터블이기 때문이다
- 공변성 사용하기
- 공변성: 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격
- 코틀린 컴파일러가 공변성을 허용해서 제네릭 베이스 타입이 요구되는 곳에 제네릭 파생 타입이 허용되도록 하기 위해 타입 프로젝션이 필요하다
- 예제코드
// copy.kts open class Fruit class Banana : Fruit() class Orange : Fruit() fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } } val fruitsBasket1 = Array<Fruit>(3) { _ -> Fruit() } val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() } copyFromTo(fruitsBasket1, fruitsBasket2) // no problem val fruitsBasket3 = Array<Fruit>(3) { _ -> Fruit() } val fruitsBasket4 = Array<Banana>(3) { _ -> Fruit() } copyFromTo(fruitsBasket3, fruitsBasket4) // type mismatch
- 코틀린은 Array<Fruit> 자리에 Array<Banana>를 전달하지 못하도록 막는다 ( Array<T> 타입은 변경할 수 없다 )
- copyFromTo 함수의 from 변수는 파라미터의 값을 읽기만 하기 때문에 Array<T>의 객체로 Fruit 클래스나 Fruit 클래스의 하위 클래스가 전달되더라도 아무런 위험이 없다
- 공변성 파라미터 타입 사용
- 예제코드
// copyout.kts open class Fruit class Banana : Fruit() class Orange : Fruit() fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } } val bananaBasket = Array<Banana>(3) { _ -> Banana() } val fruitsBasket = Array<Fruit>(3) { _ -> Fruit() } copyFromTo(bananaBasket, fruitsBasket) // no probleam
- from[i] = Fruit() 혹은 from.set(i, to[i])와 같이 from 파라미터를 수정하는 호출이 있다면 컴파일이 실패한다
- Array<T> 클래스는 T타입의 객체를 읽고, 쓰는 메서드 모두 가지고 있다
- 하지만 공변성을 사용하기 위해서 Array<T> 파라미터에서 어떤 값도 추가하거나 변경하지 않겠다는 약속을 해야 한다
- 이런 제네릭 클래스를 사용하는 관점에서 공변성을 이용하는 걸 사용처 가변성 혹은 타입 프로젝션이라고 부른다
- 공변성을 사용하면 컴파일러에게 자식 클래스를 부모 클래스의 자리에 사용할 수 있게 요청할 수 있다
- 예제코드
- 반공변성 사용하기
- in 키워드는 메소드가 파라미터에 값을 설정할 수 있게 만들고, 값을 읽을 수 없게 만든다
- 예제코드
// copyin.kts open class Fruit class Banana : Fruit() class Orange : Fruit() fun copyFromTo(from: Array<out Fruit>, to: Array<in Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } } val bananaBasket = Array<Banana>(3) { _ -> Banana() } val things = Array<Any>(3) { _ -> Fruit() } copyFromTo(bananaBasket, things)
- <in T>로 정의되면 전체적으로 파라미터 타입을 받을 수만 있고 리턴하거나 다른 곳으로 보낼 수는 없는 반공변성으로 특정된다
- where를 사용한 파라미터 타입 제한
- 제네릭은 파라미터에 여러 타입을 쓸 수 있도록 유연함을 제공해준다
- 여러 타입을 사용할 수 있지만 제약조건이 필요한 경우도 있다
- 예제코드
// closeerr.kts //fun <T> useAndClose(input: T) { // input.close() // unresolved reference: close //} fun <T: AutoCloseable> useAndClose(input: T) { input.close() // no problem }
- 하나의 제약조건을 넣기 위해서 파라미터 타입 뒤에 콜론을 넣은 후 제약조건을 정의하면 된다
- 여러개의 제약조건을 걸기 위해서는 where를 사용해야한다
- 예제코드
// where.kts fun <T> useAndClose(input: T) where T : AutoCloseable, T : Appendable { input.append("there") input.close() } val writer = java.io.StringWriter() writer.append("hello") useAndClose(writer) println(writer) // hellothere
- 스타 프로젝션
- Java는 개발자가 raw 타입을 직접 만들 수 있다 ( ex. ArrayList )
- 하지만 raw 타입을 직접 만드는 것은 일반적으로 타입 안정성이 없고 가급적 하지 말아야 할 일이다
- 파라미터 타입을 정의하는 스타 프로젝션<*>은 제네릭 읽기전용 타입과 raw타입을 위한 코틀린 기능이다
- 즉 스타 프로젝션은 타입에 대해 정확히는 알 수 없지만 타입 안정성을 유지하면서 파라미터를 전달할 때 사용된다
- 읽는 것만 허용되고, 쓰는 것은 허용되지 않는다 ( out T와 동일하지만 더 간결하게 작성할 수 있다 )
7. 구체화된 타입 파라미터
- Java에서 제네릭을 사용할 때 Class<T>를 함수에 파라미터로 전달해야 하는 지저분한 코드를 볼 수있다
- 코틀린은 구체화된 타입 파라미터를 이용해서 해당 문제를 해결했다
- 예제코드: Java 스타일의 장황한 코드버전
// reifiedtype.kts abstract class Book(val name: String) class Fiction(name: String) : Book(name) class NonFiction(name: String) : Book(name) val books: List<Book> = listOf( Fiction("Moby Dick"), NonFiction("Learn to Code"), Fiction("LOTR") ) fun <T> findFirst(books: List<Book>, ofClass: Class<T>): T { val selected = books.filter { book -> ofClass.isInstance(book) } if(selected.size == 0) { throw RuntimeException("Not found") } return ofClass.cast(selected[0]) } println(findFirst(books, NonFiction::class.java).name) // Learn to Code
- 바이트코드로 컴파일되면서 파라미터 타입 T가 지워지기 때문에 함수 안에서 T를 book is T나 selected[0] as T처럼 연산자와 함께 사용할 수 없다
- 함수가 호출될 때마다 프로그래머는 실행 시간 타입 정보를 추가적인 아규먼트로 전달해야만 한다
- 이는 호출하는 쪽과 받아주는 쪽 모두에게 나쁜 코드를 만들게 된다
- 코틀린은 실행시간에 사용할 수 없는 파라미터 타입을 reified라고 마크하고 함수가 inline으로 선언되었다면 사용할 수 있도록 권한을 준다
- 개선코드
// reifiedtype_after.kts abstract class Book(val name: String) class Fiction(name: String) : Book(name) class NonFiction(name: String) : Book(name) val books: List<Book> = listOf( Fiction("Moby Dick"), NonFiction("Learn to Code"), Fiction("LOTR") ) inline fun <reified T> findFirst(books: List<Book>): T { val selected = books.filter { book -> book is T } if (selected.size == 0) { throw RuntimeException("Not found") } return selected[0] as T } println(findFirst<NonFiction>(books).name) // Learn to Code
- 파라미터 타입 T를 reified로 선언하고 Class<T> 파라미터를 제거했다
- 함수 안에서 T를 타입 체크와 캐스팅용으로 사용 가능하다
- 함수가 inline으로 선언되어 있기 때문에(reified는 inline 함수에서만 사용 가능하다) 함수의 바디가 함수 호출하는 부분에서 확장된다
- 코드가 확장될 때 타입 T는 컴파일 시간에 확인되는 실제 타입으로 대체된다
- 구체화된 타입 파라미터는 클러터를 지우는 데 유용하고, 잠재적인 코드의 오류를 제거하는 데에도 도움이 된다
- 또한 추가적인 타입 파라미터 정보를 전달하지 않도록 만들어주고, 코드에서 캐스팅을 안전하게 하는 데 도움을 준다
728x90
반응형