Null 의 표현 방식
기존 자바
...OrNull
이라는 네이밍 사용@Nullable
,@NotNullable
애노테이션 사용
자바 8
Optional
등장
코틀린의 Null 처리 전략
null
을 포용한다.- 코틀린에서는
Optional
을 쓰면, Null 가능성을 지원하기 위한 설계를 활용할 수 없다. Optional<String>
과String?
는 큰 차이가 있음String?
는String
의 하위타입이지만,Optional<String>
은String
의 하위 타입이 아님.Optional
때문에 코드가 번잡해지고, 리팩토링 시에도 고쳐야 할 코드가 많아짐.
코드 보기
리팩토링 전 (자바)
public class Legs {
public static Optional<Leg> findLongestLegOver(
List<Leg> legs,
Duration duration
) {
Leg result = null;
for (Leg leg : legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.getPlannedDuration())
) {
result = leg;
}
}
return Optional.ofNullable(result);
}
private static boolean isLongerThan(Leg leg, Duration duration) {
return leg.getPlannedDuration().compareTo(duration) > 0;
}
}
Leg
란 여행 구간을 말한다.findLongestLegOver
는 일정 기간보다 긴Leg
중 가장 긴 기간을 가진Leg
를 찾아낸다.
1차 리팩토링 -> 코틀린으로
object Legs {
@JvmStatic
fun findLongestLegOver(
legs: List<Leg>,
duration: Duration
): Optional<Leg> {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return Optional.ofNullable(result)
}
private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
return leg.plannedDuration.compareTo(duration) > 0
}
}
- 언어만 코틀린으로 변경되었다.
- 코틀린은
null
이 될 수 없는 파라미터를 지정하면, 컴파일러가null
이 들어올 때null
검사를 하게 된다.- 자바와 다르게
null
참조에 대한 에러를 가장 가까운 시점에 알아낼 수 있다.
- 자바와 다르게
- 위의 언어에서 코틀린의
for in
구문을 사용하는데,Iterable
이 아닌 타입도 아래의 조건이 맞으면 반복이 가능하다.Iterable
을 확장한 타입Iterator
를 반환하는iterator()
메서드를 제공하는 타입Iterator
를 반환하는T.iterator()
확장 함수가 영역 안에 정의된 T 타입
두번째 세번째의 경우, 해당 타입을
Iterable
로 만들어주진 못한다.
만일Iterable
로 바꿔준다면,Iterable<T>
의 확장함수로 정의된map
,reduce
를 이용할 수 있어서 아쉬운 점이다.
2차 리팩토링 -> 인터페이스 유지, 로직 분리
object Legs {
@JvmStatic
fun findLongestLegOver(
legs: List<Leg>,
duration: Duration
): Optional<Leg> {
var result: Leg? = longestLegOver(legs, duration)
return Optional.ofNullable(result)
}
fun longestLegOver(legs: List<Leg>, duration: Duration): Leg? {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return result
}
private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
return leg.plannedDuration.compareTo(duration) > 0
}
}
findLongestLegOver()
의 인터페이스는 유지하고, 새로운 인터페이스를 가진fun longestLegOver()
를 추가했다.- 한번에
Optional
인터페이스를 없애지 않고,Leg?
를 반환하는 작은 함수를 하나 더 생성했다. - 이제 두가지 인터페이스가 생겼다.
- 자바는 이전의
findLongestLegOver()
를 그대로 사용하고, 코틀린은 새롭게 만들어진longestLegOver()
를 사용할 수 있다.
- 자바는 이전의
기존
findLongestLegOver()
코드에 대한 테스트를 하나씩longestLegOver()
에 대한 테스트로 변환할 수 있다.
3차 리팩토링 -> 코틀린 null
처리에 따른 클라이언트 코드 변환하기
- 테스트 코드 변환 전
class LongestLegOverTests {
private val legs = listOf(
leg("one hour", Duration.ofHours(1)),
leg("one day", Duration.ofDays(1)),
leg("two hours", Duration.ofHours(2))
)
private val oneDay = Duration.ofDays(1)
@Test
fun is_absent_when_no_legs() {
assertEquals(
Optional.empty<Any>(),
findLongestLegOver(emptyList(), Duration.ZERO)
)
}
@Test
fun is_absent_when_no_legs_long_enough() {
assertEquals(
Optional.empty<Any>(),
findLongestLegOver(legs, oneDay)
)
}
@Test
fun is_longest_leg_when_one_match() {
assertEquals(
"one day",
findLongestLegOver(legs, oneDay.minusMillis(1))
.orElseThrow().description
)
}
@Test
fun is_longest_leg_when_more_than_one_match() {
assertEquals(
"one day",
findLongestLegOver(legs, Duration.ofMinutes(59))
.orElseThrow().description
)
}
private fun leg(description: String, duration: Duration): Leg {
val start = ZonedDateTime.ofInstant(
Instant.ofEpochSecond(ThreadLocalRandom.current().nextInt().toLong()),
ZoneId.of("UTC"));
return Leg(description, start, start.plus(duration));
}
}
- 테스트 코드 변환 후
class LongestLegOverTests {
private val legs = listOf(
leg("one hour", Duration.ofHours(1)),
leg("one day", Duration.ofDays(1)),
leg("two hours", Duration.ofHours(2))
)
private val oneDay = Duration.ofDays(1)
@Test
fun `is absent when no legs`() {
assertNull(longestLegOver(emptyList(), Duration.ZERO))
}
@Test
fun `is absent when no legs long enough`() {
assertNull(longestLegOver(legs, oneDay))
}
@Test
fun `is longest leg when one match`() {
assertEquals(
"one day",
longestLegOver(legs, oneDay.minusMillis(1))
!!.description
)
}
@Test
fun `is longest leg when more than one match`() {
assertEquals(
"one day",
longestLegOver(legs, Duration.ofMinutes(59))
?.description
)
}
private fun leg(description: String, duration: Duration): Leg {
val start = ZonedDateTime.ofInstant(
Instant.ofEpochSecond(ThreadLocalRandom.current().nextInt().toLong()),
ZoneId.of("UTC"));
return Leg(description, start, start.plus(duration));
}
}
- 반환 타입
Optional
을 처리하기 위해 사용되었던 몇가지 코드가 사라졌다. orElseThrow()
는!!
와?.
으로 변환되었다.!!
는 값이null
인 경우NullPointerException
예외를 던진다.?.
는 값이null
이 아닌 경우에만 평가를 계속하며,null
이면 그냥null
을 반환해버린다.
여기서 사용된 리팩터링 방식인 확장과 축소 리팩터링
- 새 인터페이스를 추가한다.
- 예전 인터페이스를 사용하는 부분을 찾아 새 인터페이스를 사용하도록 변경한다.
- 예전 인터페이스가 쓰이는 곳이 사라지면, 예전 인터페이스를 삭제한다.
4차 리팩토링 -> 함수를 최상위로 이동
fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
var result: Leg? = null
for (leg in legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.plannedDuration))
result = leg
}
return result
}
private fun isLongerThan(leg: Leg, duration: Duration) =
leg.plannedDuration.compareTo(duration) > 0
- 코틀린에서는
class Legs { ... }
와 같은 추가적인 네임스페이스가 불필요하다.- 최상위로 올려도 정상 동작한다.
isLongerThan()
함수 정의 형태가 단순하게a = b
형태로 변환되었다.- 단일식 함수구문이라고 한다.
5차 리팩토링 -> 알고리즘 리팩토링
fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
return if (longestLeg != null && longestLeg.plannedDuration > duration)
longestLeg
else
null
}
- 사실 위의 알고리즘이 하고싶은 일을 다시 정리해보면, 가장 긴 기간의
Leg
를 뽑아, 그 기간이 인자로 받은duration
보다 크다면 반환하고 아니면null
을 반환하는 것이다. - 코틀린에서 비교할 수 있는 타입 (
Comparator
구현) 의 배열에는maxByOrNull()
메서드가 제공된다.- 배열 중 최대값을 반환하고 값이 없으면
null
을 반환한다.
- 배열 중 최대값을 반환하고 값이 없으면
- 아래의
if
는 코틀린에서 삼항연산자처럼 쓸 수 있는 것 같다.
6차 리팩토링 -> 엘비스 연산자 (?:
)
fun longestLegOver(
legs: List<Leg>,
duration: Duration
): Leg? {
val longestLeg = legs.maxByOrNull(Leg::plannedDuration) ?:
return null
return if (longestLeg.plannedDuration > duration)
longestLeg
else
null
}
- 엘비스 연산자를 통해
longestLeg
의 값이null
이라면, 바로null
을 반환한다.
7차 리팩토링 -> when
이용하기
fun List<Leg>.longestOver(duration: Duration): Leg? {
val longestLeg = maxByOrNull(Leg::plannedDuration)
return when {
longestLeg == null -> null
longestLeg.plannedDuration > duration -> longestLeg
else -> null
}
}
- 훨씬 명시적으로 변했다.
리팩토링 이전 코드 보기
public class Legs {
public static Optional<Leg> findLongestLegOver(
List<Leg> legs,
Duration duration
) {
Leg result = null;
for (Leg leg : legs) {
if (isLongerThan(leg, duration))
if (result == null ||
isLongerThan(leg, result.getPlannedDuration())
) {
result = leg;
}
}
return Optional.ofNullable(result);
}
private static boolean isLongerThan(Leg leg, Duration duration) {
return leg.getPlannedDuration().compareTo(duration) > 0;
}
}
이전보다,
- 단번에 이 함수가 하는 일이 무엇인지 알기 어렵다.
Optional
타입의 값을 반환하여 코틀린식null
포용의 이점을 이용할 수 없다.
소감
null
안정성 때문에 자바에서 만들게 되는 장황한 부가 코드가 사라져서 보기 좋다.- 그 외에도 최상위 함수 선언, 엘비스 연산자,
when
,maxByOrNull()
과 같은 코틀린 스타일의 코드는 자바에 비해서 코드 가독성에 확실히 좋은 영향을 미친다.
반응형
'코틀린 (Kotlin) > 자바에서 코틀린으로' 카테고리의 다른 글
자바에서 코틀린으로 6장 - 자바에서 코틀린 컬렉션으로 요약 (0) | 2022.12.06 |
---|---|
자바에서 코틀린으로 5장 - 빈에서 값으로 요약 (0) | 2022.11.30 |
자바에서 코틀린으로 3장 - 자바클래스에서 코틀린 클래스로 요약 (0) | 2022.11.30 |