Kotlin의 방어적 복사, 뼛속까지 안전하게 복사하기

Hyemdooly·2023년 2월 27일
1

방어적 복사의 필요성

class Numbers(numbers: List<Int>) {
    val numbers:List<Int> = numbers
}

다음과 같이 Int형 List를 갖는 Numbers가 있다고 하자

fun main() {
    val list = mutableListOf(1, 2, 3, 4, 5, 6)
    val numbers = Numbers(list)
    println(numbers.numbers) // [1, 2, 3, 4, 5, 6]
    list.add(7) // 바꾼닷!
    println(numbers.numbers) // [1, 2, 3, 4, 5, 6, 7]
}

listNumbers 객체 생성자에 넣어주고, list에 element를 추가하면 numbers.numbers의 요소도 바뀌는 것을 확인할 수 있다. 객체 생성시 리스트의 reference가 넘어가기 때문에(얕은 복사), 원본이 바뀌기 때문이다.

class Numbers(numbers: List<Int>) {
    val numbers:List<Int> = numbers.toList() // 새 리스트로 반환!
}
fun main() {
    val list = mutableListOf(1, 2, 3, 4, 5, 6)
    val numbers = Numbers(list)
    println(numbers.numbers) // [1, 2, 3, 4, 5, 6]
    list.add(7) // 바꾼닷!
    println(numbers.numbers) // [1, 2, 3, 4, 5, 6]
}

위와 같이 Numbers 에서 numbers.toList() 를 활용하여 새 리스트를 받아 넣어준다면, 원본이 아닌 새 리스트가 참조된다(깊은 복사). 따라서 원본이 바뀌더라도 Numbers 객체의 numbers는 그대로임을 확인할 수 있다.

캐스팅을 조심하세요!

그렇다면, 내부에서는 변경 가능한 리스트를, 외부에서는 변경 안되는 보기 전용 리스트를 쓰고 싶다면? 다음과 같이 쓸 수 있겠다.

class Numbers(numbers: List<Int>) {
    private val _numbers: MutableList<Int> = numbers.toMutableList()
    val numbers: List<Int> // backing property
        get() = _numbers

    fun add(number: Int) {
        _numbers.add(number)
    }

    fun clear() {
        _numbers.clear()
    }
}

이렇게 작성하면 객체 생성할 때 불변인 List를 MutableList로 새로 생성하여 _numbers가 참조한다. backing property를 사용하여 numbers를 호출하면 _numbers겉보기에는 외부에서 건드릴 수 없는 List형으로 리턴한다. 만약 외부에서 List를 컨트롤하고 싶으면 클래스에 구현되어 있는 add()clear() 함수를 사용하면 된다.

하지만 다음 코드를 보자

fun main() {
    val numbers = Numbers(listOf(1, 2, 3, 4, 5, 6))
    val numbers2 = numbers.numbers as MutableList
    numbers2[0] = 7 // 첫 번째 요소를 7로 바꾸고
    numbers2.add(8) // 8을 추가한다
    println(numbers.numbers) // [7, 2, 3, 4, 5, 6, 8]
}

MutableList로 형변환하여 사용하니 바꾸고 싶지 않았던 numbers.numbers도 바뀌어 있는 모습을 확인할 수 있다.

// Collections.kt
public interface MutableList<E> : List<E>, MutableCollection<E> { // ...

List와 MutableCollection을 상속받는 MutableList의 형태도 확인할 수 있다. List에 구현되어 있지 않은 add()와 같은 함수가 MutableList에는 구현되어 있고 numbers가 MutableList 출신이기 때문에, List로 왔지만 다운캐스팅을 하면 사용할 수 있는 것이다.

// Java Decompile
public final class MainKt {
   public static final void main() {
      Numbers numbers = new Numbers(CollectionsKt.listOf(new Integer[]{1, 2, 3, 4, 5, 6}));
      List var10000 = numbers.getNumbers();
      if (var10000 == null) {
         throw new NullPointerException("null cannot be cast to non-null type kotlin.collections.MutableList<kotlin.Int>");
      } else {
         List numbers2 = TypeIntrinsics.asMutableList(var10000);
         numbers2.set(0, 7);
         numbers2.add(8);
         List var2 = numbers.getNumbers();
         System.out.println(var2);
      }
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

Java로 디컴파일 해보면, Kotlin 코드에서 MutableList든 List든 모두 Java에서는 List로 처리하고 있다.

결국, 또 같은 주소를 참조하고 있기 때문에 발생하는 문제이다.

class Numbers(numbers: List<Int>) { // backing property
		private val _numbers: MutableList<Int> = numbers.toMutableList()
		val numbers: List<Int>
				get() = _numbers.toList() // toList()를 쓴다!
		
		fun add(number: Int) {
				_numbers.add(number)
		}
		
		fun clear() {
				_numbers.clear()
		}
}

numbers를 호출하면, toList()를 사용하여 새로운 리스트를 반환해주면 된다. 이렇게 처리한다면 원본은 바뀌지 않고, 외부에서는 필요하면 다운캐스팅하여 리스트를 변환하여 사용할 수 있을 것이다.

만약 List Element가 가변 객체라면?

먼저 var number을 갖는 Number 클래스를 만들었다. Numbers는 방금 사용한 코드와 그대로이지만 편의를 위해 함께 첨부한다.

class Number(var number: Int) {
    override fun toString(): String = number.toString()
}
class Numbers(numbers: List<Number>) {
    private val _numbers: MutableList<Number> = numbers.toMutableList()
    val numbers: List<Number>
        get() = _numbers.toList()

    fun add(number: Number) {
        _numbers.add(number)
    }

    fun clear() {
        _numbers.clear()
    }
}
fun main() {
    val numberObjects = (1..6).map { Number(it) } // 1~6까지 Number 객체 모음
    val numbers = Numbers(numberObjects)
    val numbers2 = numbers.numbers as MutableList

    println(numbers.numbers) // [1, 2, 3, 4, 5, 6]
    (numberObjects as MutableList)[0].number = 7 // 원본 바꾸기
    numbers2[1].number = 8 // numbers에서 가져온 number 바꾸기
    println(numbers.numbers) // [7, 8, 3, 4, 5, 6]
}

Numbers 객체를 생성할 때 분명 새 리스트로 복사했으나, Element들은 복사되지 않고 원본 그대로를 참고하고 있기 때문에 일어나는 일이다. Element로 갖고 있는 가변 객체 역시 복사하여 넣어주어야 원본을 지킬 수 있을 것이다.

복사 방법

Clonable 상속 받고 clone() 구현하기

class Number(var number: Int) : Cloneable {
    override fun toString(): String = number.toString()
    public override fun clone(): Number {
        return super.clone() as Number
    }
}
class Numbers(numbers: List<Number>) {
		// 하나하나 clone()으로 복사하기
    private val _numbers: MutableList<Number> = numbers.map { it.clone() }.toMutableList()
    val numbers: List<Number>
        get() = _numbers.toList()

    fun add(number: Number) {
        _numbers.add(number)
    }

    fun clear() {
        _numbers.clear()
    }
}

위 예제에서는 Number가 primitive type의 변수만 가지고 있기 때문에 비교적 쉽게 복사가 가능하지만, Collection이라도 가지고 있는 경우에는 또 다시 Element는 복사되지 않는 상황, 즉 깊은 복사가 안되는 상황이 발생하니 더 세세하게 구현해야 한다.

data class의 copy() 활용하기

data class로 정의하면 toString(), hashCode(), equals(), copy() 를 자동으로 구현되도록 한다.

data class Number(var number: Int) // data class로 변경
class Numbers(numbers: List<Number>) {
		// copy() 함수 사용
    private val _numbers: MutableList<Number> = numbers.map { it.copy() }.toMutableList()
    val numbers: List<Number>
        get() = _numbers.toList()

    fun add(number: Number) {
        _numbers.add(number)
    }

    fun clear() {
        _numbers.clear()
    }
}

복사를 해주는 copy() 함수를 사용한다면, 따로 구현 없이 예쁘게 코드를 짤 수 있다.

0개의 댓글