어떤 클래스에 대한 확장 함수를 정의할 때 이를 멤버로 추가하는 것은 좋지 않다.
확장 함수는 아래와 같이 첫번째 아규먼트를 리시버로 받는 단순한 일반 함수로 컴파일된다.
fun String.isPhoneNumber(): Boolean =
length == 7 && all { it.isDigit() }
// 컴파일 되면 아래와 같이 변한다.
fun isPhoneNumber('$this': String): Boolean =
'$this'.length == 7 && '$this'.all { it.isDigit() }
이렇게 단순하게 변환되는 것이므로, 확장 함수를 클래스 멤버로 정의하거나 인터페이스 내부에 정의할 수도 있다.
interface PhoneBook {
fun String.isPhoneNumber(): Boolean
}
class Fizz: PhoneBook {
override fun String.isPhoneNumber(): Boolean =
length == 7 && all { it.isDigit() }
}
이런 코드가 가능하지만, DSL을 만들때를 제외하면 사용하지 않는 것이 좋다. 특히 가시성 제한을 위해 확장 함수를 멤버로 정의하는 것은 굉장히 좋지 않다.
class PhoneBookIncorrect {
// ...
fun String.isPhoneNumber() =
length == 7 && all { it.isDigit() }
}
사용하지 말아야 하는 큰 한가지 이유는 가시성을 제한하지 못한다는 것이다. 이러한 확장 함수를 사용하려면 아래와 같이 이상한 형태가 된다.
PhoneBookIncorrect().apply { "12345".isPhoneNumber() }
확장 함수의 가시성을 제한하고 싶다면 멤버로 만들지 않고, 가시성 한정자를 붙여주면 된다.
class PhoneBookIncorrect {
// ...
}
private fun String.isPhoneNumber() =
length == 7 && all { it.isDigit() }
fun main() {
"12345".isPhoneNumber()
}
멤버 확장 함수를 피해야 하는 이유를 정리하면 아래와 같다.
-
레퍼런스를 지원하지 않는다.
class PhoneBookIncorrect { // ... fun String.isPhoneNumber() = length == 7 && all { it.isDigit() } } val refX = PhonebookIncorrect::isPhoneNumber // 오류
-
암묵적 접근을 할 때, 두 리시버 중 어떤 리시버가 선택될지 혼동된다.
class A { val a = 10 } class B { val a = 20 val b = 30 fun A.test() = a + b // 결과는 40일까? 50일까 }
-
확장 함수가 외부에 있는 다른 클래스를 리시버로 받을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않다.
class A { /** **/ } class B { // .. fun A.update() = ... // A, B 중 어떤 것을 업데이트할까? }
- 경험이 적은 개발자의 경우 확장 함수를 보면, 직관적이지 않거나, 심지어 보기만 해도 겁먹을 수 있다.
멤버 확장 함수를 사용하는 것에 대한 단점을 인지하고 사용하지 않는 것이 좋다.
Reference
- 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김
개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.
오류가 있다면 댓글을 남겨주세요.