KotlinInAction/코틀린 타입 시스템

컬렉션과 배열

webmaster 2022. 8. 7. 18:07
728x90

널 가능성과 컬렉션

컬렉션 안에 널 값을 넣을 수 있는지 여부는 어떤 변수의 값이 널이 될 수 있는지 여부와 마찬가지로 중요하다. 변수 타입 뒤에 ?를 붙이면 그 변수에 널을 저장할 수 있다는 뜻인 것처럼 타입 인자로 쓰인 타입에도 같은 표시를 사용할 수 있다.

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을 저장할 수 있다.

어떤 변수의 널 가능성과 타입 파라미터로 쓰이는 타입의 널 가능성의 차이를 보자

각 데이터가 널인 경우와 list 전체가 널인 경우

  • 첫 번째 경우 리스트 자체는 항상 널이 아니다.
    • 리스트에 들어있는 각 원소가 널이 될 수 있다
  • 두 번째 경우 리스트를 가리키는 변수에 널이 들어갈 수 있지만 리스트 안에는 널이 아닌 값만 들어간다
  • 경우에 따라 널이 될 수 있는 값으로 이뤄진 널이 될 수 있는 리스트를 정의해야 할 경우 List<Int?>?로 표현한다
    • 변수에 대해 널 검사를 수행한 다음 그 리스트에 속한 모든 원소에 대해 다시 널 검사를 수행해야한다
fun main(args: Array<String>) {
    val reader = BufferedReader(StringReader("1\nabc\n42"))
    val numbers = readNumbers(reader)
    addValidNumbers(numbers)
}

fun addValidNumbers(numbers: List<Int?>){
    var sumOfValidNumbers = 0
    var invalidNumbers = 0
    for(number in numbers){ //리스트에서 널이 될수 있는 값을 읽는다
        if(number != null){
            sumOfValidNumbers += number //널에 대한 값을 확인한다
        }else{
            invalidNumbers++
        }
    }
    println("Sum Of Valid Numbers : $sumOfValidNumbers")
    println("Invalid numbers: $invalidNumbers")
}
  • 리스트에 원소에 접근하면 Int? 타입의 값을 얻는다.
    • 그 값을 산술식에 사용하기 전에 널 여부를 검사해야 한다
fun main(args: Array<String>) {
    val reader = BufferedReader(StringReader("1\nabc\n42"))
    val numbers = readNumbers(reader)
    addValidNumbers(numbers)
}

fun addValidNumbers(numbers: List<Int?>) {
    val validNumbers = numbers.filterNotNull()
    println("Sum Of Valid Numbers : ${validNumbers.sum()}")
    println("Invalid numbers: ${numbers.size - validNumbers.size}")
}
  • 널이 될 수 있는 값으로 이뤄진 컬렉션으로 널 값을 걸러내는 경우가 자주 있어서 코틀린 표준 라이브러리는 filterNotNull이라는 함수를 제공한다
  • 걸러내는 연산도 컬렉션의 타입에 영향을 끼친다.
    • filterNotNull이 컬렉션 안에 널이 들어 있지 않음을 보장해 주므로 validNumbers는 List<Int> 타입이다

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

코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다.

코틀린의 컬렉션을 다룰 때 사용하는 가장 기초적인 인터페이스인 kotlin.collections.Collection 인터페이스를 사용하면 컬렉션 안에 원소에 대해 이터레이션 하고,  컬렉션의 크기를 얻고, 어떤 값이 컬렉션 안에 들어있는지 검사하고 컬렉션에서 데이터를 읽는 여러 다른 연산을 수행할 수 있다.

하지만, 컬렉션에 데이터를 수정하려면 kotlin.collections.MutableCollection 인터페이스를 사용하자. MutableCollection은 일반 인터페이스인  Collection을 확장하면서 원소를 추가, 삭제, 원소를 모두 지우는 메서드를 제공한다.

코드에서 가능하면 항상 읽기 전용 인터페이스를 사용하는 것을 일반적인 규칙으로 삼고, 변경이 필요할 때만 변경 가능 버전을 사용해라. 어떤 컴포넌트의 내부 상태에 컬렉션이 포함된다면 그 컬렉션을 MutableCollection을 인자로 받는 함수에 전달할 때는 어쩌면 원본의 변경을 막기 위해 컬렉션을 복사해야 할 수도 있다.

fun main(args: Array<String>) {
   val source: Collection<Int> = arrayListOf(3, 5, 7)
   val target: MutableCollection<Int> = arrayListOf(1)
   copyElements(source, target)
   println(target)
}

fun <T> copyElements(source: Collection<T>,
                     target: MutableCollection<T>){
    for(item in source){
        target.add(item) //변경 가능한 target 컬렉션에 원소를 추가
    }
}
  • target에 해당하는 인자로 읽기 전용 컬렉션을 넘길 수 없다.
  • 실제 그 값이 변경 가능한 컬렉션인지 여부와 관계없이 선언된 타입이 읽기 전용이라면 target에 넘기면 컴파일 오류가 난다
  • 주의할 점은 읽기 전용 컬렉션이라고 해서 꼭 변경 불가능한 컬렉션일 필요는 없다
    • 읽기 전용 인터페이스 타입인 변수를 사용할 때 그 인터페이스는 실제로는 어떤 컬렉션 인스턴스를 가리키는 수많은 참조 중 하나일 수 있다
    • 그럴 경우 이 컬렉션을 참조하는 다른 코드를 호출하거나 병렬 수행한다면 컬렉션을 사용 중에 컬렉션 내용이 변하는 상황이 생길 수 있고 ConcurrentModificaionException이나 다른 오류가 발생할 수 있다
    • 읽기 전용 컬렉션은 "항상 스레드에 안전하지 않다"라는 것을 명심하자

코틀린 컬렉션과 자바

모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스이다. 따라서 코틀린과 자바 사이를 오갈 때 아무 변환도 필요 없다. 또한 래퍼 클래스를 만들거나 데이터를 복사할 필요도 없다.

코틀린 컬렉션 인터페이스

  • 코틀린은 모든 자바 컬렉션 인터페이스마다 읽기 전용 인터페이스와 변경 가능한 인터페이스 두 가지 표현을 제공한다
  • 코틀린의 읽기 전용과 변경 가능 인터페이스 기본 구조는 java.util 패키지에 있는 자바 컬렉션 인터페이스의 구조를 그대로 옮겨놓았다
  • 변경 가능한 각 인터페이스는 자신과 대응하는 읽기 전용 인터페이스를 확장한다.
    • 변경 가능한 인터페이스는 java.util 패키지에 있는 인터페이스와 직접적 연관되지만 읽기 전용 인터페이스에는 컬렉션을 변경할 수 있는 모든 요소가 빠져있다

List, Set, Map

  • setOf와 mapOf는 자바 표준 라이브러리에 속한 클래스의 인스턴스를 반환한다.
    • 내부에서 변경 가능한 클래스다
    • 하지만 변경 가능한 클래스라는 사실에 의존하면 안 된다
  • 자바 메서드를 호출하되 컬렉션을 인자로 넘겨야 한다면 따로 변환하거나 복사하는 등의 추가 작업 없이 직접 컬렉션을 넘기면 된다
public class CollectionUtils {
  public static List<String> uppercaseAll(List<String> items) {
    for (int i = 0; i < items.size(); ++i) {
      items.set(i, items.get(i).toUpperCase());
    }
    return items;
  }
}
fun printInUppercase(list: List<String>){ //읽기 전용 파라미터를 선언
    println(CollectionUtils.uppercaseAll(list))//컬렉션을 변경하는 자바 함수를 호출
    println(list.first())//컬렉션이 변경됬는지 살펴본다
}
fun main(args: Array<String>) {
    val list = listOf("a", "b", "c")
    printInUppercase(list)
}
  • 컬렉션을 자바로 넘기는 코틀린 프로그램을 작성한다면 호출하려는 자바 코드가 컬렉션을 변경할지 여부에 따라 올바른 파라미터 타입을 사용할 책임은 개발자에게 있다
  • 이런 함정은 널이 아닌 원소로 이뤄진 컬렉션 타입에서도 발생한다
    • 널이 아닌 원소로 이뤄진 컬렉션을 자바 메서드로 넘겼는데 자바 메서드가 널을 컬렉션에 넣을 수도 있다
    • 코틀린에서 이를 금지할, 널 값이 들어왔는지 감지할 방법이 없다
    • 따라서 자바 코드에게 넘길 때는 특별히 주의를 기울이며 코틀린 쪽 타입이 적절히 자바 쪽에서 컬렉션에게 가할 수 있는 변경의 내용을 반영하게 해야 한다

https://github.com/mash-up-spring/Kotlin-In-Action/blob/main/Chapter06/%EC%A0%95%EA%B7%A0/Chapter06.md

 

GitHub - mash-up-spring/Kotlin-In-Action: 코틀린 인 액션 스터디

코틀린 인 액션 스터디. Contribute to mash-up-spring/Kotlin-In-Action development by creating an account on GitHub.

github.com

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

플랫폼 타임의 경우 코틀린 쪽에는 널 관련 정보가 없다. 따라서 컴파일러는 코틀린 코드가 그 타입을 널이 될 수 있는 타입이나 널이 될 수 없는 타입 어느 쪽으로든 사용할 수 있게 허용한다. 마찬가지로 자바 쪽에서 선언한 컬렉션 타입의 변수를 코틀린에서는 플랫폼 타입으로 본다. 플랫폼 타입인 컬렉션은 기본적으로 변경 가능성에 대해 알 수 없기 때문에 코틀린 코드는 그 타입을 읽기 전용 컬렉션이나 변경 가능한 컬렉션 어느 쪽으로든 다룰 수 있다.

하지만 컬렉션 타입이 시그니처에 들어간 자바 메서드 구현을 오버라이드 하려는 경우 읽기 전용 컬렉션과 변경 가능 컬렉션의 차이가 문제가 된다. 플랫폼 타입에서 널 가능성을 다룰 때처럼 오버라이드 하려는 메서드의 자바 컬렉션 타입을 어떤 코틀린 컬렉션 타입으로 표현할지 결정해야 한다.

아래 3가지 기준을 통해 코틀린에서 사용할 컬렉션 타입에 반영해야 한다

  • 컬렉션이 널이 될 수 있는가?
  • 컬렉션의 원소가 널이 될 수 있는가?
  • 오버라이드 하는 메서드가 컬렉션을 변경할 수 있는가?
public interface FileContentProcessor {
  void processContents(File path, byte[] binaryContents, List<String> textContents);
}
  • 일부 파일은 이진 파일이며, 이진 파일 안의 내용은 텍스트로 표현할 수 없는 경우가 있으므로 리스트는 널이 될 수 있다
  • 파일의 각 줄은 널일 수 없으므로 이 리스트의 원소는 널이 될 수 없다
  • 이 리스트는 파일의 내용을 표현하며 그 내용을 바꿀 필요가 없으므로 읽기 전용이다

코틀린으로 구현해보자

class FileIndexer :FileContentProcessor {
    override fun processContents(
        path: File?,
        binaryContents: ByteArray?,
        textContents: MutableList<String>?
    ) {
        //logic
    }
}

 

다른 예

public interface DataParser<T> {
  void parseData(String input, List<T> output, List<String> errors);
}
  • 호출하는 쪽에서 항상 오류 메시지를 받아야 하므로 List<String>은 널이 되면 안 된다
  • errors의 원소는 널이 될 수 도 있다. output에 들어가는 정보를 파싱 하는 과정에서 오류가 발생하지 않으면 그 정보와 연관된 오류 메시지는 널이다
  • 구현 코드에서 원소를 추가할 수 있어야 하므로 List<String> 은 변경 가능해야 한다
class PersonParser: DataParser<Person> {
    override fun parseData(
        input: String?,
        output: MutableList<Person>,
        errors: MutableList<String?>
    ) {
        //Logic
    }
}
  • 이전은 List<String> , 현재는 MutableList<String?> 을 사용한다
  • 이런 선택을 제대로 하려면 자바 인터페이스나 클래스가 어떤 맥락에서 사용되는지 정확히 알아야 한다

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

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를 쓰지 않고 각 원소가 널이 아닌 배열을 만들어야 하는 경우 이 생성자를 사용한다
fun main(args: Array<String>) {
    val letters = Array<String>(26){i -> ('a' + i).toString()}
    println(letters.joinToString(" "))
}
  • 람다는 배열 원소의 인덱스를 인자로 받아서 배열의 해당 위치에 들어갈 원소를 반환한다
    • 여기서는 인덱스 값에 a문자 값을 더한 결과를 문자열로 반환
    • String 타입 인자를 굳이 지정했지만 생략해도 컴파일러가 알아서 원소 타입을 추론해준다
fun main(args: Array<String>) {
    val strings = listOf("a", "b", "c")
    println("%s/%s/%s".format(*strings.toTypedArray()))	vararg 인자를 넘기기 위해 스프레드 연산자를 사용
}
  • 코틀린에서는 배열을 인자로 받는 자바 함수를 호출하거나 vararg 파라미터를 받는 코틀린 함수를 호출하기 위해 자주 배열을 만드는데 이때 데이터가 이미 컬렉션에 들어 있다면 컬렉션을 배열로 변환해야 한다.(toTypedArray 메서드를 사용)
  • 다른 제네릭 타입에서처럼 배열 타입의 타입 인자도 항상 객체 타입이 된다
    • Array<Int> 같은 타입을 선언하면 그 배열은 박싱 된 정수의 배열이다.
    • 박싱하지 않은 원시 타입의 배열이 필요하면 그런 타입을 위한 특별한 배열 클래스를 이용해야 한다.

코틀린에서는 원시 타입의 배열을 표현하는 별도 클래스를 각 원시 타입마다 하나씩 제공한다

fun main(args: Array<String>) {
    val fiveZeros = IntArray(5)
    val fiveZerosToo = intArrayOf(0, 0, 0, 0, 0)
}

원시 타입 배열을 만드는 방법은 다음과 같다

  • 각 배열 타입의 생성자는 size를 인자를 받아서 해당 원시 타입의 디폴트 값으로 초기화된 size 크기의 배열을 반환한다.
  • 팩토리 함수는 여러 값을 가변 인자로 받아서 그런 값이 들어간 배열을 반환한다
  • 크기와 람다를 인자로 받는 생성자를 사용한다

람다를 인자로 받는 생성자를 사용하는 방법

fun main(args: Array<String>) {
    val squares = IntArray(5){ i -> (i+1) * (i+1)}
    println(squares.joinToString())
}
  • 이 밖에 박싱된 값이 들어있는 컬렉션이나 배열이 있다면 toIntArray 등의 변환 함수를 사용해 박싱 하지 않은 값이 들어있는 배열로 변환할 수 있다
fun main(args: Array<String>) {
    args.forEachIndexed{ index, element ->
        println("Argument $index is: $element")
    }
}
  • 코틀린 표준 라이브러리는 배열 기본 연산에 더해 컬렉션에 사용할 수 있는 모든 확장 함수를 배열에도 제공한다.
  • map, filter와 같은 함수를 배열에 써도 잘 동작한다.
  • 원시 타입인 원소로 이뤄진 배열에도 그런 확장 함수를 똑같이 사용할 수 있다
  • forEachIndexed는 배열의 모든 원소를 갖고 인자로 받은 람다를 호출해 준다
    • 이때 , 배열의 원소와 그 원소의 인덱스를 람다에게 인자로 전달해 준다
728x90

'KotlinInAction > 코틀린 타입 시스템' 카테고리의 다른 글

코틀린의 원시 타입  (0) 2022.08.07
널 가능성(2)  (0) 2022.08.06
널 가능성(1)  (0) 2022.08.06