Lesson 03 / 23
Basic Types
Introduces the number types, the character, and the logical value; measures that the criterion of equality changes once a nullable `Int` is boxed.
Published Updated
In this lesson
- Distinguishes the roles of the `Int`, `Long`, `Double`, `Char`, and `Boolean` types
- Shows that conversion between number types is written explicitly and that comparison does not match the types
- Explains why boxed numbers compared with `===` come out inconsistent
- States that the value `Double.NaN` gives different equality depending on the static type
Contents
In the previous lesson we saw that the type inferred from the constant 7 is Int. Now we look at the basic types: Int and Long for integers, Double for floating-point numbers, Char for a single character, and Boolean for the logical value. The question of equality has two faces for these types. First, can numbers of different types be compared with ==? Second, when a number is boxed, can the same value split into two objects?
Types and Explicit Conversion
In Kotlin, an integer constant is an Int by default. A constant that exceeds the Int limit becomes a Long on its own; the L suffix is written to make a small value a Long as well. A constant with a decimal point is a Double. A Char is written in single quotes and gives its numeric code with .code. Conversions are explicit: an Int is not assigned to a Long variable automatically; you write count.toLong(). Arithmetic, on the other hand, can mix types: when an Int and a Double are added, the result is a Double.
In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.
// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
val sameObject = first === twin
val equalTwin = first == twin
val equalOther = first == other
val name: String = when {
sameObject -> "singleton"
!equalTwin -> "identity"
!equalOther -> "content"
else -> "partial-content"
}
fun report() {
println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
}
}
fun summarize(checks: List<Criterion>) {
for (name in listOf("singleton", "identity", "content", "partial-content")) {
println("$name: ${checks.count { it.name == name }}")
}
}
The program below runs all of these rules together. The total variable converts an Int value to a Long and multiplies it by a billion. 7 / 2 and 7 / 2.0 compare two divisions: in the first both sides are integers, in the second one side has a decimal point. letter + 1 adds a number to a Char.
// Main.kt — numbers, character, logical value, and explicit conversion
fun main() {
val count: Int = 3
val total: Long = count.toLong() * 1_000_000_000L
val size = 2.5
val letter = 'K'
val ready = count > 2
println(count + size)
println(total)
println(7 / 2)
println(7 / 2.0)
println(letter.code)
println(letter + 1)
println(ready)
}
5.5 3000000000 3 3.5 75 L true
count + size gives 5.5: an Int and a Double were added and the result became a Double. total prints 3000000000; because this value exceeds the Int limit, a Long was needed. 7 / 2 gives three, because both sides are integers, so the division is carried out as an integer division and the fractional part is discarded. 7 / 2.0, on the other hand, gives 3.5. letter.code prints 75, the code of the capital letter K. letter + 1 gives the letter L, the next character. The last line shows that the ready variable is true.
LB16. In this section the division was measured with the constants 7 and 2; the behavior of division with a negative number is measured in a separate section.
LB17. Char arithmetic was measured only with letter + 1; the type of the difference between two Char values was not measured in this lesson.
Conversion Is Explicit, and So Is Comparison
That arithmetic can mix types does not hold for assignment and comparison. Assigning an Int value to a variable of type Long is rejected by the compiler; we saw this by compiling in the sandbox, so the fragment is without output.
val count: Int = 3 val wide: Long = count
The compiler reports that it found an Int where a Long was expected. Comparison follows the same rule: comparing an Int with a Long constant by == again does not compile.
val count: Int = 1 println(count == 1L)
The compiler reports that the == operator cannot be applied between Int and Long. The solution is to write the conversion explicitly: count.toLong() == 1L. But if the types are held as Any, the situation changes: the compiler cannot know the static type, so it asks for equality of the run-time objects.
// Main.kt — numbers held as Any
fun main() {
val a: Any = 1
val b: Any = 1L
println(a == b)
}
false
1 and 1L did not come out equal when held as Any: the output is false. An Int object and a Long object belong to different classes even if their numeric values are the same, and equals takes this difference into account. So the comparison that the compiler rejected is not rejected once its type is hidden, but its answer is still false.
LB18. The compiler errors were obtained with Kotlin 2.4.21; the message wording may change in later versions, but what is measured in this lesson is the rule that the two comparisons are rejected.
Boxed Numbers and NaN
The measurement of the lesson looks at two things. First, Int?, a nullable type, holds a boxed number; on the JVM this is an Integer object. Are two boxed numbers the same object even when they have the same value? Second, Double.NaN (the not-a-number value) gives different equality depending on the static type. First the comparison in which both sides are Double is printed, then the comparison in which the same value is held as Any.
// Main.kt — boxed numbers and NaN
fun main() {
val boxedBig: Int? = 1000
val boxedBigTwin: Int? = 1000
val boxedSmall: Int? = 127
val boxedSmallTwin: Int? = 127
val nan = Double.NaN
val anyNan: Any = nan
val anyNanTwin: Any = Double.NaN
println(nan == nan)
println(anyNan == anyNan)
val checks = listOf(
Criterion("boxed-1000", boxedBig, boxedBigTwin, 1001),
Criterion("boxed-127", boxedSmall, boxedSmallTwin, 128),
Criterion("nan-as-any", anyNan, anyNanTwin, 1.0),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
false true boxed-1000: same-object=false, twin=true, other=false -> content boxed-127: same-object=true, twin=true, other=false -> singleton nan-as-any: same-object=false, twin=true, other=false -> content singleton: 1 identity: 0 content: 2 partial-content: 0
The first two lines are NaN equality. nan == nan, because the static type is Double, follows the IEEE rule and gives false; NaN is not equal to itself. anyNan == anyNan gives true, because when the static type is Any, == calls the object’s equals method, and equals treats NaN as equal to itself. The same number gives two different answers depending on the type it is held in.
Among the three measurement lines, boxed-1000 is content: the two Int? variables have the value 1000 but are separate objects, same-object=false. boxed-127 is singleton: the same value points to the same object on the JVM, same-object=true. Two numbers with the same type and the same form of value fell under different criteria according to the value. nan-as-any is content: with the twin == gives true, and they are separate objects.
LB19. 127 and 1000 were measured on the JVM, with Kotlin 2.4.21; no general limit on which values are shared is drawn in this lesson, and no claim is made for values other than 127 and 1000.
LB20. The NaN observation was measured with a single Double.NaN value held as Any; that Float.NaN gives the same result was not measured in this lesson.
Overflow, Division by Zero, and Decimal Error
That a type has a limit calls for knowing what happens when the limit is exceeded. The block below shows three limit cases together: a sum that exceeds the Int limit, integer division by zero, and decimal division by zero. It then tests the error that decimal numbers inherit from binary representation with 0.1 + 0.2. The Math.addExact call, which catches overflow as an error, is a method of the JVM itself; Kotlin can call it directly.
// Main.kt — overflow, division by zero, and decimal error
fun main() {
val zero = 0
val big = 3_000_000_000
println(Int.MAX_VALUE + 1)
println(Long.MAX_VALUE)
println(big::class.simpleName)
try {
Math.addExact(Int.MAX_VALUE, 1)
} catch (e: ArithmeticException) {
println(e.message)
}
try {
println(1 / zero)
} catch (e: ArithmeticException) {
println(e.message)
}
println(1.0 / 0.0)
println(0.1 + 0.2)
println(0.1 + 0.2 == 0.3)
}
-2147483648 9223372036854775807 Long integer overflow / by zero Infinity 0.30000000000000004 false
Int.MAX_VALUE + 1 does not raise an error; it silently becomes -2147483648: the number wraps from the largest value to the smallest. For Long.MAX_VALUE, 9223372036854775807 is printed. Because the constant 3_000_000_000 exceeds the Int limit, type inference makes it a Long by itself; the Long in the output confirms this. Math.addExact, on the other hand, throws an error with the message integer overflow for the same sum; so if you want overflow to be noticed, you choose this path.
When an integer is divided by zero, the message / by zero comes. In decimal division the same operation raises no error and returns Infinity. The last two lines show the limit of the decimal number: the result of 0.1 + 0.2 is 0.30000000000000004, and the == comparison with 0.3 gives false.
The common mistake appears in two places. The first is forgetting that overflow raises no error and accumulating a sum in an Int. The second is comparing two decimal numbers with ==; for values that must come out exact, such as money, a decimal type is the wrong choice. Java comparison: these outputs are the same as in Java, because Kotlin uses the JVM’s own arithmetic here; Kotlin does not require a separate type to be written for this behavior. This sameness is stated on the basis of the run-time environment being the same, not by running Java code.
LB21. Overflow was measured with an Int sum, and division by zero with Int and Double; that Long overflow and Float give the result in the same form was not measured in this lesson.
Narrowing Conversions
A widening conversion causes no trouble; the trouble appears where a value is fitted into a narrower type. The block below measures five conversions and the conversion from text to number: converting 300 to Byte, decimal numbers to Int, 65 to Char, and a valid or an invalid string to Int.
// Main.kt — narrowing conversions and number from text
fun main() {
println(300.toByte())
println(3.99.toInt())
println((-3.99).toInt())
println(65.toChar())
println("42".toInt() + 1)
println("x".toIntOrNull())
try {
"x".toInt()
} catch (e: NumberFormatException) {
println(e.message)
}
}
44 3 -3 A 43 null For input string: "x"
300.toByte() cannot carry more than eight bits and becomes 44: only the low eight bits remain, the upper bits are discarded, and no error is raised. 3.99.toInt() does not round; it truncates the fractional part, and 3 comes out. For a negative value, (-3.99).toInt() also gives -3, so there is truncation toward zero, not rounding down. 65.toChar() gives the capital letter A; the bridge between a number and a character code is explicit.
There are two ways to convert text to a number. "42".toInt() + 1 gives 43 on valid text. On invalid text the two options part ways: toIntOrNull() returns null instead of throwing an error, while toInt() throws a NumberFormatException with the message For input string: "x". For text that comes from a user, the first way asks for a null check but does not bring the program down, while the second way does.
The reason for the mechanism is that conversions are written explicitly. The compiler does not assign silently to a narrow type; writing toByte() says that you knowingly accept the loss of information. In Java, the same narrowing is written with a cast of the form (byte) 300, and it is often overlooked.
LB22. The conversions were measured with Byte, Int, and Char; that the same truncation rules hold for Short, Float, and Long was not measured in this lesson.
Division and Modulo with Negative Numbers
In the first section we saw that 7 / 2 discards the fractional part. But what if the dividend is negative? The block below shows four ways of writing a negative division and remainder operation, then three other limit cases together: the absolute value of the smallest Int, hexadecimal and binary constant notation, and ordering between two Char values.
// Main.kt — division and modulo with negative numbers
import kotlin.math.abs
fun main() {
println(-7 / 2)
println(-7 % 2)
println((-7).mod(2))
println(Math.floorMod(-7, 2))
println(abs(Int.MIN_VALUE))
println(0xFF)
println(0b1010)
println('a' < 'b')
}
-3 -1 1 1 -2147483648 255 10 true
-7 / 2 gives -3; the fractional part is discarded toward zero, not downward. -7 % 2, on the other hand, gives -1: the sign of the remainder follows the sign of the dividend. If you want the remainder always to come out zero or positive, you need (-7).mod(2) or the JVM’s Math.floorMod(-7, 2) call; both gave 1. A common mistake is forgetting that a negative remainder can come out when converting the index of an array with %.
abs(Int.MIN_VALUE) gives -2147483648; even the absolute value does not come out positive, because +2147483648 has no counterpart in Int. The constant 0xFF prints 255, and the constant 0b1010 prints 10; writing a constant in hexadecimal or binary does not change the type itself, only how it is written. The expression 'a' < 'b' gives true, because Char values are ordered by character code.
Java comparison: the % operator in Java also follows the sign of the dividend. The mod that Kotlin adds offers, as a method next to the operator, the result obtained with Math.floorMod in Java. Both of these results were observed in the output above.
LB23. Negative division and remainder were measured with a single pair (-7, 2); that the same sign rule holds for a negative divisor or for the Long type was not measured in this lesson.
Habits That Come from the Measurements
In numeric types, Kotlin’s guarantees are limited, and this lesson’s measurements showed where the limit begins. Overflow is silent: the output of Int.MAX_VALUE + 1 was -2147483648 and no exception was thrown. This behavior is the same as in Java. Where overflow can be harmful, for example in a money calculation or in the sum of durations, you need to use Long or to write checked operations such as Math.addExact. For a Long, the same overflow lies at a much more distant limit and is enough in most cases, but it does not remove the limit.
Division by zero throws an exception for integers (/ by zero), while for decimal numbers it gives Infinity. This difference means that when the type of a calculation is changed, the error behavior changes too. A piece of code that uses Double quietly keeps running when divided by zero and spreads the result; to notice the error, an isInfinite or isNaN check is needed. Because decimal numbers are kept in base two, the output of 0.1 + 0.2 was 0.30000000000000004 and the == 0.3 comparison gave false. Where an exact value is wanted, such as money, BigDecimal or an integer converted to the smallest currency unit is used.
Narrowing conversions also call for attention. Converting from a larger type to a smaller one truncates or wraps the number, and results such as 44, 3, -3, and A in the measurement showed that this is not always “rounding”: toInt discards the decimal part, so -3.99 becomes -3. toIntOrNull returns null for a string that cannot be converted, while toInt throws an exception in the same case, and the message For input string: "x" confirmed this behavior. When converting text from a user to a number, choosing the OrNull form makes the error a part of the flow.
Finally, the negative division and modulo measurement showed that the sign of the result follows the same rule as in Java: division rounds toward zero, and the remainder carries the sign of the dividend. The modulo in mathematics and the modulo operation in a program are not the same thing; in a circular index calculation that works with negative numbers, code that uses % instead of mod can push the index below zero.
The Bounding Measurement
For a boxed number, the criterion depends on the value: 1000 is content, 127 is singleton. For this reason, comparing the identity of numbers with === is unreliable; number comparison is done with ==. The same value came out singleton at 127 and content at 1000. Instead of a single criterion, we saw two criteria.
NaN sets a separate limit: in a comparison whose static type is Double, NaN is not equal, and under the type Any it is equal. The meaning of the == operator changes at the point where the operator depends on the static type of the expression on the left.
Summary
- An integer constant is an
Int; a value that exceeds the limit asks for aLong. A constant with a decimal point is aDouble. - An
Intvalue is not assigned to aLongvariable automatically, and anIntand aLongconstant cannot be compared with==. - Arithmetic can mix types: the sum of an
Intand aDoubleis aDouble. - A nullable
Intis boxed; 127 came out singleton and 1000 came out content. - This lesson’s criterion: content (1000); bounding measurement: the value 127 is a singleton on the JVM, and NaN gives different equality depending on the static type.
Next Step
We have seen the numbers and the character. In the next lesson we return to strings: templates, raw strings, and trimIndent. We will measure whether building a string with a template gives the same object as writing the same text by hand.
To keep your progress and take notes, Log in
My notes
Log in to take notes.