Skip to content
academia.sh

Lesson 03 / 24

Basic Types

Introduces the `Int`, `Double`, `Bool` and `String` types; measures that conversion is explicit and that a conversion expression is evaluated immediately.

Published Updated

In this lesson

  • Tell apart the roles of the `Int`, `Double`, `Bool` and `String` types
  • State that conversion between types is written explicitly and when it is performed
  • Explain why a floating-point comparison gives an unexpected result
  • Show that tuple elements are evaluated from left to right, even when they are not read
Contents

We saw the moment of assignment for a constant and a variable: on the line where it is defined, immediately. Now we look at the assigned value itself. Swift has four basic types: Int for integers, Double for floating-point numbers, Bool for logical values and String for text. This lesson asks one question: when is an expression that converts one type to another evaluated?

Conversion Is Explicit

Swift does not mix two types on its own. If you try to add an Int and a Double, the compiler rejects the program: the snippet below does not compile in Swift 6.4, which is why it has no output.

let count = 1
let size = 2.5
print(count + size)

The fix is to write the conversion: Double(count). A conversion is an expression of its own, and if it is written inside a let, it is evaluated once, on that line. This lesson’s measurement confirms it. First the helper file that stays the same in every lesson.

// Probe.swift — records when an expression is evaluated
final class Probe {
    let label: String
    private(set) var evaluations = 0
    private(set) var uses = 0
    private var evaluatedBeforeUse = false

    init(_ label: String) {
        self.label = label
    }

    func eval<T>(_ value: T) -> T {
        evaluations += 1
        if uses == 0 { evaluatedBeforeUse = true }
        return value
    }

    func use<T>(_ access: () -> T) -> T {
        uses += 1
        return access()
    }

    var moment: String {
        if evaluations == 0 { return "never" }
        if evaluations > 1 { return "on-every-use" }
        return evaluatedBeforeUse ? "immediately" : "on-first-use"
    }

    func report() {
        print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
    }
}

func tally(_ probes: [Probe]) {
    for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
        let count = probes.filter { $0.moment == moment }.count
        print("\(moment): \(count)")
    }
}

The measurement starts with two Probe objects: one tracks the conversion, and the other tracks a tuple element. Next to the conversion there are two floating-point observations and a print of Int.max, because why a Double comparison surprises is this lesson’s subject. The helper function marked shows the order of the tuple elements with print. unusedProbe tracks a tuple element that is never read.

// main.swift — conversion and tuple moments
let conversionProbe = Probe("type-conversion")
let tupleProbe = Probe("tuple-element")
let unusedProbe = Probe("unused-element")

let wholeCount = 7
let ratio = conversionProbe.eval(Double(wholeCount) / 2)
_ = conversionProbe.use { ratio }
_ = conversionProbe.use { ratio }
print(ratio)
print(Int.max)
print(0.1 + 0.2 == 0.3)
print(0.1 + 0.2)

func marked(_ n: Int) -> Int { print("value \(n)"); return n }
let triple = (marked(1), marked(2), marked(3))
print(triple.1)

let record = (size: tupleProbe.eval(3), spare: unusedProbe.eval(9))
_ = tupleProbe.use { record.size }
_ = tupleProbe.use { record.size }

let allProbes = [conversionProbe, tupleProbe, unusedProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
3.5
9223372036854775807
false
0.30000000000000004
value 1
value 2
value 3
2
type-conversion: 1 evaluations, 2 uses -> immediately
tuple-element: 1 evaluations, 2 uses -> immediately
unused-element: 1 evaluations, 0 uses -> immediately
immediately: 3
on-first-use: 0
on-every-use: 0
never: 0

ratio gives seven divided by two as 3.5, because the division was done on Double. Int.max prints the largest Int value; one more than this value is a program error and was left out of the lesson’s measurement. 0.1 + 0.2 == 0.3 gives false, against expectation: the addition does not produce exactly 0.3 but a number very close to it. Because a floating-point number cannot be represented exactly in base two, you measure a difference instead of testing equality; this is not a Swift feature but a consequence of the Double representation.

The conversion line was evaluated once, on the line where it is defined, before the first use: type-conversion came out immediately with two uses. The tuple elements were evaluated from left to right: value 1, value 2, value 3. A tuple is a structure that gathers several values into a single value; its elements are ready on the definition line.

LB16. The Int.max print is valid only for a 64-bit Int; the compiler used for the measurement compiled for a 64-bit target.

LB17. 0.1 + 0.2 == 0.3 was measured with two constant expressions; whether the evaluation that does the arithmetic happens in the compiler or at run time is not told apart in this lesson.

Numeric Type Initializers

Swift does not convert implicitly between different numeric types; you write the conversion. The tool for it is the type’s initializer: Double(7), Int(3.9), UInt8(200). The initializer that turns a fractional value into an Int drops the fractional part, that is, it truncates toward zero; Int(3.9) gives 3, and Int(-3.9) gives -3. If you need rounding, you write a separate operation.

To measure this behavior, the block below produces a fractional number through a Probe and passes it through two different initializers. The initializer is the expression itself; so the eval call that produces the number sits inside the argument of the initializer.

// Convert.swift — conversion with initializers
let truncateProbe = Probe("truncating-conversion")
let widenProbe = Probe("widening-conversion")
let cut = Int(truncateProbe.eval(3.9))
let negativeCut = Int(truncateProbe.eval(-3.9))
let wide = Double(widenProbe.eval(7))
let small = UInt8(widenProbe.eval(200))
_ = truncateProbe.use { cut + negativeCut }
_ = truncateProbe.use { cut - negativeCut }
_ = widenProbe.use { wide }
_ = widenProbe.use { small }
print(cut, negativeCut, wide, small)
truncateProbe.report()
widenProbe.report()
3 -3 7.0 200
truncating-conversion: 2 evaluations, 2 uses -> on-every-use
widening-conversion: 2 evaluations, 2 uses -> on-every-use

Int(3.9) gave 3 and Int(-3.9) gave -3; that is, the conversion truncates toward zero. Double(7) was printed as 7.0, and UInt8(200) stayed 200. Both measurement lines showed two evaluations: each initializer call evaluated its argument once, and because each Probe is tied to two initializers, the count rose to two. That is why both lines read on-every-use; the count is the product of the measurement harness, not of the conversion.

What matters here is the place where an initializer can fail: a value like UInt8(300) does not fit this type, and the program stops. So when you write a conversion, you need to know in advance that the value fits the target type. The lesson does not run this case.

LB18. The conversions were measured only with values that fit the type; the case of a value that does not fit is not run in this lesson, only described.

Overflow Operators

Integer types have limits. A UInt8 value stays between 0 and 255. If the regular + operator pushes the result past this limit, the program stops. Swift also offers operators that accept overflow: &+, &- and &*. These operators wrap the result around according to the type’s capacity. The & prefix is the way to say that you want overflow on purpose; it does not happen silently, it happens by choosing the operator.

The block below adds a number to a UInt8 value with &+ inside a function. Because the added number comes through a Probe, each call counts as one evaluation. The first call adds 10 to 250 and passes the limit; the second call works with a safe value.

// Wrap.swift — the overflow operator wraps around
func bump(_ value: UInt8, probe: Probe) -> UInt8 {
    value &+ probe.eval(10)
}
let wrapProbe = Probe("overflow-operator")
print(wrapProbe.use { bump(250, probe: wrapProbe) })
print(wrapProbe.use { bump(1, probe: wrapProbe) })
let floor: UInt8 = 0
print(floor &- 1, UInt8.max)
wrapProbe.report()
4
11
255 255
overflow-operator: 2 evaluations, 2 uses -> on-every-use

250 &+ 10 did not come out 260; the result was 4, because after 255 the count returned to 0. 1 &+ 10 gave the normal 11. On the third line, 0 &- 1 came out 255, the same as UInt8.max: the number that went below the limit wrapped around to the upper end. The measurement line shows two evaluations: each bump call evaluated its operand once, so the moment is on-every-use.

Overflow operators can be measured because the program does not stop. The same overflow with the regular + does stop it, and for that reason it is not run in this lesson. Which one you choose depends on your intent: write &+ if you want wrapping, and + if you want overflow to be an error.

LB19. The overflow operator was measured with UInt8; the wrapping behavior of signed types with &+ was not measured in this lesson.

Type Aliases and Limits

Every numeric type has a lower and an upper limit, and the type exposes them as the static properties min and max: Int8.min is -128, Int8.max is 127, UInt8.max is 255. Knowing a type’s limit answers the overflow question on the spot. When you want to give a type another name in code, you use typealias: typealias Pixel = UInt8. An alias is not a new type; it is a second name for the same type and carries its limits and behavior unchanged.

The block below defines the Pixel alias, binds a constant with it, and prints the constant’s type and limits. The constant comes from a Probe and is read twice.

// Alias.swift — a type alias is the same type
typealias Pixel = UInt8
let aliasProbe = Probe("alias-constant")
let brightness: Pixel = aliasProbe.eval(200)
_ = aliasProbe.use { brightness }
_ = aliasProbe.use { brightness &+ 100 }
print(type(of: brightness), Pixel.max, Int8.min)
aliasProbe.report()
UInt8 255 -128
alias-constant: 1 evaluations, 2 uses -> immediately

type(of: brightness) printed UInt8, so the alias did not create a new type. Pixel.max came out 255 and Int8.min came out -128. The Probe showed a single evaluation, and because the constant was set up before the use, the moment is immediately. An alias is only a convenience at the level of names; it adds nothing at run time.

Knowing the limit properties is the basis of conversion and overflow decisions. You can check ahead of time whether a value will fit by comparing it with max; that way you never run a conversion that does not fit.

LB20. The limit values were shown with UInt8 and Int8; the limits of the other integer types lie outside this lesson.

Converting a Fraction to an Integer

There is not a single way to turn a Double value into an Int, and three common ways give different results. Int(value) drops the fractional part, that is, it truncates toward zero. value.rounded() rounds to the nearest whole value; on an exact midpoint it goes away from zero. value.rounded(.down) always goes down to the smaller one. The three either produce a Double first or give an Int directly; which way you choose changes the result for negative numbers.

The block below converts one number in three ways. The value to convert comes from a Probe, and the function is called with a positive and a negative value.

// Rounding.swift — converting a fraction to an integer
func convert(_ value: Double, probe: Probe) -> (Int, Int, Int) {
    let source = probe.eval(value)
    return (Int(source), Int(source.rounded()), Int(source.rounded(.down)))
}
let roundProbe = Probe("rounding")
print(roundProbe.use { convert(2.5, probe: roundProbe) })
print(roundProbe.use { convert(-2.5, probe: roundProbe) })
roundProbe.report()
(2, 3, 2)
(-2, -3, -3)
rounding: 2 evaluations, 2 uses -> on-every-use

For positive 2.5 the three ways gave (2, 3, 2): truncation 2, rounding to nearest 3, rounding down 2. For negative -2.5 the result was (-2, -3, -3). Truncation moves toward zero, so it gave -2; rounding to nearest goes away from zero at the exact midpoint, so it gave -3; and rounding down always goes to the smaller one, so it also gave -3. For a positive number, truncation and rounding down give the same result; for a negative number they part ways. The Probe showed two evaluations for two calls, so the moment reads on-every-use.

When you choose the conversion way, what you should ask is whether negative values will also arrive. If they will not, two of the three ways do the same job; if they will, the rule of each is a separate decision.

LB21. Rounding was measured with 2.5 and -2.5; the other rounding rules (.up, .toNearestOrEven) lie outside this lesson.

Decimal Equality

Binary floating-point numbers cannot hold some simple fractions of the base-ten system exactly. 0.1, 0.2 and 0.3 are the best-known of these: each is rounded to the nearest representable value. Adding two numbers and comparing the result with a third using == can therefore give an unexpected result. When you compare decimal numbers, you choose an acceptable difference limit instead of exact equality.

The block below measures this case. The first term of the sum comes from a Probe. The block prints the sum, the result of exact equality, and whether the difference stays under a small limit.

// Equality.swift — decimal equality
func sum(probe: Probe) -> Double {
    probe.eval(0.1) + 0.2
}
let equalProbe = Probe("decimal-sum")
let total = equalProbe.use { sum(probe: equalProbe) }
print(total)
print(total == 0.3)
print(abs(total - 0.3) < 0.000001)
equalProbe.report()
0.30000000000000004
false
true
decimal-sum: 1 evaluations, 1 uses -> on-first-use

The sum printed 0.30000000000000004, exact equality gave false, and the comparison with a small limit came out true. Because 0.1 and 0.2 cannot be written exactly in base two, their sum did not meet 0.3 at the same value; the difference is in the last digit. That is why == is not a reliable test for computed decimal values. The measurement showed a single evaluation: the first term was taken from the Probe once, and the later comparisons did not recompute it, so the moment is on-first-use.

The size of the limit is chosen to fit the job; here 0.000001 was a sufficient measure. For values like money, where the cent matters, working with integer units is safer than a decimal type.

Choose the rounding rule by intent, not by looking at the result. If you want to cut a sum of money or a counter downward, you write .down; if you want the nearest value, rounded(); if you want only the whole part, Int(value). Treating the three as the same causes a wrong result on the day the first negative value arrives. If the Double being converted is outside the integer range, Int(value) stops the program; this lesson does not run that case.

The same principle holds for decimal equality: compare the result with a limit that fits what it should be, and do not rely on exact equality. Putting the limit into a named constant keeps the same measure consistent in more than one place.

LB22. Equality was measured with Double; the result of the same sum with types such as Float and Decimal lies outside this lesson.

The Bounding Measurement

Look at the unused-element line: an element was defined inside a tuple, evaluated once, and never read. The rule says immediately, because the evaluation happened before the use and there was no use at all. That means a tuple element is evaluated even when it is not read; never appears only when the expression is never evaluated. This line also shows why the measurement condition asks for “at least two uses”: with zero uses, the rule cannot tell a wasted evaluation from a real immediately.

LB23. Zero uses was left on this line on purpose; no other observation in the lesson carries zero uses.

Summary

  • Int, Double, Bool and String are the four basic types; types are not mixed on their own.
  • Conversion is explicit: Double(count) is an expression and is evaluated on the line where it is defined.
  • 0.1 + 0.2 == 0.3 gives false because of the floating-point representation.
  • Tuple elements are evaluated from left to right, even when they are not read.
  • This lesson’s moment is immediately (a conversion expression, once, on the let line).

Next Step

String appeared in this lesson only by name. The next lesson goes down into strings: what is a character, why do count and utf8.count part ways, and how many times is an expression placed inside a string set up?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close