Kotlin에서는 Int 범위를 표현 하는 Int와 Integer라는 두가지의 자료형이 있다.
자바에서 버전 9부터 Integer 의 생성자는 현재 Deprecated가 된 상태지만 하위호환을 중요하게 여기는 Kotlin은 아직도 사용중이다.
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
@Deprecated(since="9", forRemoval = true)
public Integer(int value) {
this.value = value;
}
Integer는 int하나를 wrapping 하고 있다. 그리고 이를 통해서 비교 연산을 하게 된다면 object로 처리 될 것이다. 추가로 캐싱은 -128 ~ 127까지 되고 나머지는 매번 새로운 객체를 생성하여 반환하고 있다. 추가로 캐싱 범위를 수정할 수 있다.
이제 다음 아래 코드를 동등성 을 확인해보면 한 눈에 봤을 때에 actual와 expected를 Int와 Int의 관계로 비교 한다고 알 수 있다.
// Kotlin Version
@Test
fun test1() {
val actual: Int = 1
val expected: Int = 1
assertThat(actual).isEqualTo(expected)
}
// Decompile Version
@Test
public final void test1() {
int actual = 1;
int expected = 1;
Assertions.assertThat(actual).isEqualTo(expected);
}
// Assertion.assertThat().isEqualTo()
public SELF isEqualTo(int expected) {
integers.assertEqual(info, actual, expected);
return myself;
}
문제 없이 똑같이 int 타입으로 들어가게 되고 비교 또한 int 와 int 형태로 비교 하고 있다.
따라서 문제 없이 참이 나올 것이다.
이제 문제의 동일성을 확인 해보자
// Kotlin Version
@Test
fun test1() {
val actual: Int = 1
val expected: Int = 1
assertThat(actual).isSameAs(expected)
}
// Decompile Version
@Test
public final void test1() {
int actual = 1;
int expected = 1;
Assertions.assertThat(actual).isSameAs(Integer.valueOf(expected));
}
// Assertion.assetThat().isSameAs()
@Override
public SELF isSameAs(Object expected) {
objects.assertSame(info, actual, expected);
return myself;
}
확인해보면 actual 은 int로 들어가고 expected 는 Integer.valueOf(expected)를 통해서 Integer로 들어가게 된다. 이를 이상하게 여겨 왜 expected 만 Integer가 되는지 궁금하여 찾아보니 int 가 인자로 들어오고 매개 변수가 Integer 로 자동 변환 된다고 한다. 따라서 모두 Integer로 처리 하기 된다는 것이다. 과정은 아래와 같고 자세히는 알필요 없다.
// In the case of the assertThat method, the int value is autoboxed into an Integer object when it is passed to the constructor of IntegerAssert.
public static AbstractIntegerAssert<?> assertThat(int actual) {
return AssertionsForClassTypes.assertThat(actual);
}
=>
public static AbstractIntegerAssert<?> assertThat(int actual) {
return new IntegerAssert(actual);
}
=>
public class IntegerAssert extends AbstractIntegerAssert<IntegerAssert> {
=>
public IntegerAssert(Integer actual) {
super(actual, IntegerAssert.class);
}
}
@Test
public final void test1() {
int actual = 1;
int expected = 1;
Assertions.assertThat(actual).isEqualTo(expected);
Assertions.assertThat(actual).isSameAs(Integer.valueOf(expected));
}
첫번째 결과는 모두 참이 나오게 되는데 이유는 -128 ~ 127은 IntegerCache로 존재하고 오브젝트의 주소를 반환하여 사용된다.
// Kotlin Version
@Test
fun test2() {
val actual: Int = 1000
val expected: Int = 1000
assertThat(actual).isEqualTo(expected)
assertThat(actual).isSameAs(expected)
}
// Decompile Version
@Test
public final void test2() {
int actual = 1000;
int expected = 1000;
Assertions.assertThat(actual).isEqualTo(expected);
Assertions.assertThat(actual).isSameAs(Integer.valueOf(expected));
}
Test1을 통해서 Test2가 왜 안되는지 이제 바로 알 수 있을 것이다. int에서 Integer로 변환 되게 되지만 -128~127 범위에 안들어가기 때문에 새로운 객체가 생성되어 반환 된다. 따라서 오브젝트끼리의 비교로 변하기 때문에 거짓이 나오는 것이다.
Decompile을 하게 되면 항상 참이 되기에 최적화 되서 나오게 되지만 최적화 전으로 돌리게 되면 위와 같은 코드를 얻을 수 있다.
assertThat 을 들어가기 전에 비교를 하고 Boolean을 전달하고 있기 때문에 JVM의 영향으로 Integer로 변환 되지 않고 Kotlin에서 결과를 처리하게 된다.
@Test
fun test3() {
val actual: Int = 1000
val expected: Int = 1000
assertThat(actual == expected).isTrue
assertThat(actual === expected).isTrue
}
@Test
public final void test3() {
int actual = true;
int expected = true;
AbstractBooleanAssert var10000 = Assertions.assertThat(true);
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual == expected)");
var10000.isTrue();
var10000 = Assertions.assertThat(true);
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual === expected)");
var10000.isTrue();
}
이제 Nullable로 변환 하게 되면 Java에서 int 라는 자료형은 null 을 가질 수 없기 때문에 Integer로 변환 하여 진행 하게 된다. 하지만 내부적인 IntegerCache로 인해서 또 -128 ~ 127까지 같은 오브젝트들을 반환 할 것이다.
@Test
fun test4() {
val actual: Int? = 1
val expected: Int? = 1
assertThat(actual == expected).isTrue
assertThat(actual === expected).isTrue
}
@Test
public final void test4() {
Integer actual = 1;
Integer expected = 1;
AbstractBooleanAssert var10000 = Assertions.assertThat(Intrinsics.areEqual(actual, expected));
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual == expected)");
var10000.isTrue();
var10000 = Assertions.assertThat(actual == expected);
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual === expected)");
var10000.isTrue();
}
Test5도 nullable을 표현 하기 위해서 Integer로 들어가게 되고 IntegerCache 범위 밖의 값을 요청하기에 서로 다른 객체가 반환 된다는 것이다.
// Kotlin Version
@Test
fun test5() {
val actual: Int? = 1000
val expected: Int? = 1000
assertThat(actual == expected).isTrue
assertThat(actual === expected).isTrue
}
// Decompile Version
@Test
public final void test5() {
Integer actual = 1000;
Integer expected = 1000;
AbstractBooleanAssert var10000 = Assertions.assertThat(Intrinsics.areEqual(actual, expected));
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual == expected)");
var10000.isTrue();
var10000 = Assertions.assertThat(actual == expected);
Intrinsics.checkNotNullExpressionValue(var10000, "assertThat(actual === expected)");
var10000.isTrue();
}
Java에 대해서 공부를 해본 적이 없기 때문에 오브젝트끼리의 비교는 어떻게 처리하는지 궁금하여 bytecode로 뜯어보았더니 다음과 같은 결과가 나왔다.
obj1 == obj2 와 같이 두 객체를 비교할 때는 다음과 같은 bytecode를 사용하게 되는데 주소를 비교하여 값을 반환한다.
aload 1 ; load reference to obj onto the stack
aload 2 ; load reference to obj2 onto the stack
if_acmpne 8 ; jump to instruction at index 8 if obj1 != obj2
iconst_1 ; push int value 1 onto the stack (true)
goto 9 ; jump to instruction at index 9
8: iconst_0 ; push int value 0 onto the stack (false)
9: ; continue with the next instruction
=>
if_acmpne : succeeds if and only if value1 ≠ value2
우연히 알게된 kotest에서는 어떻게 동작할지 궁금해서 찾아봤다.
주의 아쉽게도 Decompile이 안되서 bytecode로 해석하게 되서 보기 어려울 수 있다.
결과부터 말하자면 아래의 코드들은 모두 전멸했다.
package domain
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs
class IntegerTest2 : ShouldSpec({
should("test1") {
val actual: Int = 1
val expected: Int = actual
actual shouldBe expected
actual shouldBeSameInstanceAs expected
}
should("test2") {
val actual: Int = 1000
val expected: Int = 1000
actual shouldBe expected
actual shouldBeSameInstanceAs expected
}
should("test3") {
val actual: Int = 1000
val expected: Int = 1000
actual shouldBe expected
actual shouldBeSameInstanceAs expected
}
should("test4") {
val actual: Int? = 1
val expected: Int? = 1
actual shouldBe expected
actual shouldBeSameInstanceAs expected
}
should("test5") {
val actual: Int? = 1000
val expected: Int? = 1000
actual shouldBe expected
actual shouldBeSameInstanceAs expected
}
})

이제 이유를 찾아보기 위해 아래와의 코드를 보면 모두 Boxing.boxInt로 Wrapping 하는 것을 볼 수 있다.
L3
LINENUMBER 9 L3
ICONST_1
ISTORE 2
L4
LINENUMBER 10 L4
ILOAD 2
ISTORE 3
L5
LINENUMBER 11 L5
ILOAD 2
INVOKESTATIC kotlin/coroutines/jvm/internal/Boxing.boxInt (I)Ljava/lang/Integer;
ILOAD 3
INVOKESTATIC kotlin/coroutines/jvm/internal/Boxing.boxInt (I)Ljava/lang/Integer;
INVOKESTATIC io/kotest/matchers/ShouldKt.shouldBe (Ljava/lang/Object;Ljava/lang/Object;)V
L6
LINENUMBER 12 L6
ILOAD 2
INVOKESTATIC kotlin/coroutines/jvm/internal/Boxing.boxInt (I)Ljava/lang/Integer;
ILOAD 3
INVOKESTATIC kotlin/coroutines/jvm/internal/Boxing.boxInt (I)Ljava/lang/Integer;
INVOKESTATIC io/kotest/matchers/types/MatchersKt.shouldBeSameInstanceAs (Ljava/lang/Object;Ljava/lang/Object;)V
그래서 해당 코드도 찾아 보았다. IntegerCache따위 없이 상남자 답게 모두 Wrapping해서 내보내고 있다.
모두 통과하지 못하는 이유를 알게 되었다.
// io.kotest:kotest-assertions-shared-jvm:5.2.3
// kotlin/coroutines/jvm/internal/Boxing.boxInt
package kotlin.coroutines.jvm.internal
internal object Boxing {
fun boxInt(i: Int): Integer = i
}
유용한 사이트 : https://youtrack.jetbrains.com boxInt