Projects · Lesson 25.6

Circle

Source
Read a radius and print the circle's circumference and area, telling apart the end of input, a read error, text that is not a number, and a number that is not a valid radius.
You'll need: Parts 1–14 — this project is the checkpoint for Text, and leans on Input, Parse, String view, Error variant and Destructor — plus Float special and Math from Part 16.

This program asks for the radius of a circle and prints its circumference and area. The arithmetic is two lines. Everything else is about the input: a person can type anything, or nothing, and a good program tells them which kind of unusable answer they gave.

It is a checkpoint for Part 14: Text — reading a line, trimming it and parsing a number — and it borrows IsFinite and Pi from Float special and Math in Part 16.

How it is put together

Every way the input can be unusable is a case of one error variant:

variant RadiusError {
    Ended,
    Unreadable,
    NotANumber,
    Negative(float64),
    NotFinite
}

ReadRadius reads, parses and checks, failing with the first case that applies. Main asks once and opens the outcome with one exhaustive match — a radius on .Success, a sentence for each case on .Failure:

flowchart LR
    read["ReadLine"] -- "end of input" --> ended["Ended"]
    read -- "other read error" --> unreadable["Unreadable"]
    read -- "a line" --> parse["ParseFloat64<br/>of the trimmed line"]
    parse -- "too large" --> nf["NotFinite"]
    parse -- "malformed" --> nan["NotANumber"]
    parse -- "a float64" --> finite{"IsFinite?"}
    finite -- "no: Inf or NaN" --> nf
    finite -- "yes" --> sign{"below zero?"}
    sign -- "yes" --> neg["Negative"]
    sign -- "no" --> ok(["the radius"])
PieceLessons it uses
ReadLine into a StringBuilderInput, String builder
.View().Trim()String view
ParseFloat64 and its catchParse, Catch
The match guard on error.kindGuard, Outcome
RadiusError and its matchError variant, Exhaustive
IsFinite, PiFloat special, Math
The builder's memoryAllocator, Destructor

Reading a line, and the end of the input

ReadLine appends one line to a builder and returns a fallible. Reaching the end of the input is not a crash but an IoError whose kind is EndOfStream, and a match guard tells it apart from every other read failure:

match ReadLine(builder) {
    .Success(_) => {},
    .Failure(error) if error.kind == IoErrorKind::EndOfStream => fail RadiusError::Ended,
    .Failure(_) => fail RadiusError::Unreadable
}

The arms are tried in order, so the guarded arm must come before the general .Failure(_). The input ends when you press Ctrl+Z and Enter on Windows or Ctrl+D elsewhere, or when piped input runs out.

Parsing, and why a number still needs checking

The line is trimmed, so " 2.5 " is read as 2.5, and then parsed. catch translates the parser's errors into this program's own cases:

let radius = ParseFloat64(builder.View().Trim()) catch {
    .Overflow(_) => fail RadiusError::NotFinite,
    else => fail RadiusError::NotANumber
};
if !IsFinite(radius) {
    fail RadiusError::NotFinite;
}
if radius < 0.0 {
    fail RadiusError::Negative(radius);
}

A successful parse is not yet a radius. The parser reads Inf and NaN, because that is how such values print, so IsFinite has to rule them out separately. A number such as 1e400 is too large for a float64; the parser reports it as an Overflow, and the program counts it as "not finite" too, since it is a number — just not one a float64 can hold.

The order of the checks matters for NaN: every comparison with NaN is false, so radius < 0.0 alone would let it through.

Negative zero

-0 parses to −0.0, and it passes radius < 0.0 because −0.0 equals 0.0. Left alone, it would print as Radius: -0.0 and Circumference: -0.0000. Adding zero turns it into a plain 0:

return radius + 0.0;

A finite radius with an infinite area

The last surprise is in Main. A radius can be finite and its area still overflow: 1e200 squared is far past the largest float64, so the area is infinity. The program checks the result before printing it:

let circumference = 2.0 * Pi * radius;
let area = Pi * radius * radius;
PrintLine();
if !IsFinite(area) {
    PrintLine("A radius of {} is too large: the area is past float64", radius);
    return 1;
}

Every refusal exits with status 1, so a script that runs the program can tell success from failure without reading the text.

No cleanup code

The builder takes memory from the allocator, yet Main has three returns and none of them frees anything. The builder's destructor runs when Main returns, whichever return it leaves by, and gives the memory back. That is Destructor doing the work a cleanup call would otherwise have to repeat on every path.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// Reading a radius the user types and working out the circle it describes. The arithmetic is two
// lines; the rest of the program is about every way the input can be unusable, and telling the
// user which one happened:
//
//     the input ended          end of input before any line arrived (Ctrl+Z, or an empty pipe)
//     the input failed         a read error, or a line that is not valid UTF-8
//     not a number             the line does not spell a number at all
//     not a radius             a number, but negative, infinite or NaN
//
// Each is a case of one error variant, so `Main` handles them in one exhaustive `match`. The
// parser reads "Inf" and "NaN" because that is how such values print, which is why a parsed
// number still has to be checked before it is trusted.
import Allocator::{ Allocator, SystemAllocator };
import Core::IsFinite;
import Format::ParseFloat64;
import Io::{ IoErrorKind, Print, PrintLine, ReadLine };
import Math::Pi;
import Text::StringBuilder;

variant RadiusError {
    Ended,
    Unreadable,
    NotANumber,
    Negative(float64),
    NotFinite
}

func ReadRadius(builder: &var StringBuilder) -> float64 ! RadiusError {
    match ReadLine(builder) {
        .Success(_) => {},
        .Failure(error) if error.kind == IoErrorKind::EndOfStream => fail RadiusError::Ended,
        .Failure(_) => fail RadiusError::Unreadable
    }

    // Trimming lets " 2.5 " through. A number too large for a float64 is reported as an
    // `Overflow`: it is a number, just not a finite one, so it joins Inf and NaN.
    let radius = ParseFloat64(builder.View().Trim()) catch {
        .Overflow(_) => fail RadiusError::NotFinite,
        else => fail RadiusError::NotANumber
    };
    if !IsFinite(radius) {
        fail RadiusError::NotFinite;
    }
    if radius < 0.0 {
        fail RadiusError::Negative(radius);
    }

    // `-0` passes the test above, since -0.0 == 0.0. Adding zero makes it a plain 0.
    return radius + 0.0;
}

func Main() -> int {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    // The builder owns memory from the allocator. Its destructor gives that memory back when
    // `Main` returns, whichever `return` it leaves by, so no path needs a cleanup call.
    var builder = StringBuilder(allocator);

    // `Print` rather than `PrintLine`, so an answer typed at the console stays on the same line.
    Print("Circle radius: ");
    match ReadRadius(builder) {
        .Success(radius) => {
            // A finite radius can still give an infinite area: 1e200 squared is past float64.
            let circumference = 2.0 * Pi * radius;
            let area = Pi * radius * radius;
            PrintLine();
            if !IsFinite(area) {
                PrintLine("A radius of {} is too large: the area is past float64", radius);
                return 1;
            }
            PrintLine("Radius:        {}", radius);
            PrintLine("Circumference: {:.4}", circumference);
            PrintLine("Area:          {:.4}", area);
            return 0;
        },
        .Failure(error) => {
            // Piped input leaves the cursor after the prompt, so the report starts a new line.
            PrintLine();
            match error {
                .Ended => PrintLine("The input ended before a radius was entered"),
                .Unreadable => PrintLine("The input could not be read"),
                .NotANumber => PrintLine("That is not a number"),
                .Negative(radius) => PrintLine("A radius cannot be negative, and {} is", radius),
                .NotFinite => PrintLine("A radius must be a finite number")
            }
            return 1;
        }
    }
}

Besides Io, its Rux.toml lists Allocator, Core, Format, Math and Text under [Dependencies].

Run it

cd Examples/Projects/Circle
rux run
Circle radius: 2.5

Radius:        2.5
Circumference: 15.7080
Area:          19.6350

Piped input works too. The typed value is not echoed then, so each report follows the prompt on a line of its own:

"-3" | rux run
"two" | rux run
$null | rux run
Circle radius:
A radius cannot be negative, and -3.0 is
Circle radius:
That is not a number
Circle radius:
The input ended before a radius was entered

Inf, NaN and numbers too large for a float64 are refused as not finite. Every refusal exits with status 1.

The program waits for you to type. Run it, type a radius after the prompt and press Enter. To try the refusals, type -3, two, Inf or 1e200, or end the input straight away with Ctrl+Z and Enter on Windows (Ctrl+D elsewhere). The piped examples above are written for PowerShell; in a POSIX shell, echo -3 | rux run does the same.

Common mistakes

Calling ReadRadius without looking at the result.
A bare ReadRadius(builder); is refused: error: fallible result of type 'float64 ! RadiusError' is discarded. The whole program is about what that result says, so it has to be matched.
Leaving a case out of the final match.
Remove the .Unreadable arm from Main and the build stops with error: match on 'RadiusError' is not exhaustive; missing RadiusError::Unreadable. This is the reason to put every failure in one variant: adding a sixth case later makes the compiler point at every match that must learn about it.
Trusting a parsed number.
Take the IsFinite check out of ReadRadius and type Inf: the program no longer refuses it as a radius, but reaches the area check and reports A radius of Inf is too large. Take the sign check out and -3 gives a negative circumference. Parsing tells you the text is a number, not that it is a sensible one.

Try it yourself

  1. Also print the diameter, and the radius of a circle with twice the area. (Math has Sqrt.)
  2. Refuse a radius of exactly zero with a case of its own, Zero, and a message to match. Let the compiler show you where the new case must be handled.
  3. Keep asking until a valid radius arrives: put the Print and the call in a loop, and leave it only on success or on Ended. Remember that the builder is reused, so it must be cleared before each read — Quadratic shows how.
  4. Ask for a second radius and print the area of the ring between the two circles.

Learn more