
신나는 라이브러리들 버전들 최신화 작업 중에 빌드 테스트 과정에서 warning 문구를 발견했다.
'fun add(element: Long): PersistentSet' is deprecated. Use
adding()instead. For more details, read the documentation for this function.
???
add 말고 adding을 쓰라고? remove 말고 removing?
무슨 작명법이지? 몰카인가?
왜 kotlinx.collections.immutable 0.5.0 버전에서 잘만 사용하던 함수들의 이름이 변경되었는지 알아보도록 하자.
warning log에 For mote details, read the documentation for this function이라는 언급이 있기에 documentation을 먼저 확인해보도록 하자.
물론 document를 직접 읽으면 이번 글을 읽을 필요가... 없기 때문에 핵심만 골라 정리해보도록 하겠다.
요약하면 이렇다.
원본을 바꾸는 함수와, 원본을 바꾸지 않고 바뀐 복사본을 돌려주는 함수는 이름만 보고도 구분되어야 한다.
Kotlin 표준 라이브러리에는 이미 비슷한 관습이 있다.
mutableList.sort() // 원본 변경
list.sorted() // 정렬된 새 리스트 반환
mutableList.reverse() // 원본 변경
list.reversed() // 뒤집힌 새 리스트 반환
mutableList.shuffle() // 원본 변경
list.shuffled() // 셔플된 새 리스트 반환
sort와 sorted, reverse와 reversed, shuffle과 shuffled는 이미 익숙하다. 이번 변화는 그 관습을 add, remove, put, clear 같은 컬렉션 변경 연산까지 확장한 것이다.
다만 여기서 중요한 건 무조건
-ed를 붙인다는 뜻이 아니라, 원본을 바꾸는 이름과 새 값을 돌려주는 이름을 구분한다는 점이다.
문제는 add라는 이름이 너무 명령형이라는 데 있다.
mutableList.add(x) // 원본 변경
persistentList.add(x) // 새 리스트 반환
둘 다 add면 호출부만 봐서는 같은 일을 하는 것처럼 보인다. 하지만 실제 의미는 다르다.
MutableList.add(x)는 원본 리스트를 바꾼다. 반면 persistent collection의 add(x)는 원본을 바꾸지 않는다. 대신 x가 추가된 새 컬렉션을 돌려준다.
이 차이는 코드를 읽는 사람을 헷갈리게 하고, 버그도 만들기 쉽다.
val ids = persistentSetOf<Long>()
ids.add(1L)
ids.add(2L)
println(ids) // [] 여전히 비어 있음
persistent collection을 잘 아는 사람이라면 "아, 반환값을 받아야지"라고 바로 알아차린다.
val ids = persistentSetOf<Long>()
.adding(1L)
.adding(2L)
하지만 add라는 이름만 보면 무심코 mutable collection처럼 읽힌다. 특히 코드 리뷰에서 타입까지 매번 따라가야 의미가 보인다면 좋은 함수 이름이라고 하긴 어렵다.
KEEP-0459에서 말하는 이 컨벤션의 이유도 여기에 있다. 변경 동작과 복사 반환 동작을 이름만 보고 구분하게 하려는 것이다.
mutableList.add(x) // 원본 변경
persistentList.adding(x) // x가 추가된 새 리스트 반환
adding은 함수명 자체는 길어졌지만, 적어도 "이건 명령이 아니라 값을 돌려주는 연산이구나"라는 힌트를 준다.
0.5.0 버전부터 kotlinx.collections.immutable은 copy-returning 메서드 이름을 분사형으로 바꿨다.
// before
val next = ids.add(1L)
// after
val next = ids.adding(1L)
대표적인 변경은 이런 식이다.
set.add(element) -> set.adding(element)
set.addAll(elements) -> set.addingAll(elements)
set.remove(element) -> set.removing(element)
set.removeAll(elements) -> set.removingAll(elements)
set.retainAll(elements) -> set.retainingAll(elements)
set.clear() -> set.cleared()
map.put(key, value) -> map.putting(key, value)
map.remove(key) -> map.removing(key)
map.clear() -> map.cleared()
list.add(index, element) -> list.addingAt(index, element)
list.removeAt(index) -> list.removingAt(index)
list.set(index, element) -> list.replacingAt(index, element)
retainAll(elements)함수는 "이것들(elements)만 남기고 나머지는 제거" 하는 함수이다.
규칙은 생각보다 단순하다.
인자로 직접 바꿀 대상이 들어오면 -ing를 붙인다.
add(element) -> adding(element)
remove(element) -> removing(element)
put(key, value) -> putting(key, value)
명시적인 대상 인자가 없고, 결과 상태를 설명하는 쪽이 자연스러우면 -ed를 붙인다.
clear() -> cleared()
sort() -> sorted()
reverse() -> reversed()
인덱스 기반 연산은 At을 붙여서 헷갈리지 않게 한다.
addingAt(index, element)
removingAt(index)
replacingAt(index, element)
여기서 set(index, element)가 setting이 아니라 replacingAt이 된 점도 눈에 띈다. setting은 영어로도 애매하고, "인덱스 위치의 값을 교체한다"는 의미가 잘 드러나지 않는다. 그래서 replacingAt이 선택됐다.
마이그레이션 자체는 단순하다. warning이 알려주는 이름으로 바꾸면 된다. AI한테 해줘 명령해도 되고
// before
selectedIds = selectedIds.add(id)
selectedIds = selectedIds.remove(id)
// after
selectedIds = selectedIds.adding(id)
selectedIds = selectedIds.removing(id)
0.5.x에서는 기존 이름이 warning으로 남아 있고, 릴리스 노트 기준으로 0.6.0에서는 compile error, 0.7.0에서는 제거될 예정이라고 한다.
원본 변경과 새 값 반환을 이름으로 구분하려는 시도는 Kotlin만의 발상은 아니다.
Swift는 이 규칙을 공식 가이드에서 이미 명확히 정리해두고 있다.
Swift API Design Guidelines에는 mutating 메서드는 명령형 동사로, nonmutating 변형은 -ed나 -ing를 붙여 이름 짓는다는 규칙이 있다.
x.sort() // 원본 변경
let y = x.sorted()
x.append(y) // 원본 변경
let z = x.appending(y)
Kotlin의 이번 변화는 Swift 쪽 관습과 거의 같은 방향이다. 원본을 바꾸는 함수는 명령처럼 읽히고, 새 값을 반환하는 함수는 변경된 결과를 만드는 연산처럼 읽힌다.
처음에는 adding이 어색한데, sorted, reversed, shuffled까지 같이 놓고 보면 그렇게 뜬금없는 이름은 아니다. 우리가 이미 sorted()에는 익숙해져 있었을 뿐이다.
Java는 Kotlin이나 Swift처럼 "mutating/nonmutating 쌍은 이렇게 이름 짓자"는 강한 규칙이 있는 쪽은 아니다.
그래도 Collectors.groupingBy(...), Collectors.joining()처럼 -ing 형태의 이름은 종종 보인다. 여기서는 컬렉션 원본 변경과 새 값 반환을 구분하려는 규칙이라기보다는, 어떤 연산을 수행하는 Collector를 만든다는 의미에 가깝다.
Kotlin 안에서도
-ing형태가 전혀 없었던 것은 아니다.sortDescending()처럼descending이 들어간 이름도 이미 있었다. 물론 이 경우의descending은 "내림차순"이라는 정렬 방향을 나타내는 말이라, 이번adding,removing과 같은 규칙으로 보기는 어렵다.
처음에는 adding, removing, putting이 꽤 어색했다. sorted, reversed, shuffled처럼 -ed 형태의 이름은 이미 눈에 익어 있었지만, 컬렉션의 add, remove, put 계열에 -ing가 붙는 모습은 아직 익숙하지 않았기 때문이다.
그래도 취지는 공감이 된다.
mutableSet.add(id) // 이 set을 바꾼다
val updatedSet = persistentSet.adding(id) // id가 추가된 새 set을 돌려준다
이 차이가 이름에서 바로 보이면, 타입을 끝까지 따라가지 않아도 코드를 읽을 수 있다. 반환값을 버리는 실수도 조금 더 눈에 띈다.
이 정도면 낯선 이름을 감수할 이유는 될듯하다.