Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Language Atlas (mini-courses for more languages)  Module 9 – D for C, C++ and Python programmers

Project: a CSV statistics tool in D

Build csvstats with dub: read CSV with std.csv, decide which cells are numbers, compute five statistics per column, test it, and read a million rows fast.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, LDC 1.43.0 and dub 1.42.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build csvstats as a dub project whose rules and statistics live in modules with unittest blocks
  • Parse CSV with std.csv and decide which cells are numbers by a written rule, not by a parser's habits
  • Compute count, mean, median, minimum and maximum per column from the nearest double of each cell
  • Measure the processor time of a million-row run and remove the cost that dominates it

Before you start

What you’ll learn · Before you start

What you will learn

  • Build csvstats as a dub project whose rules and statistics live in modules with unittest blocks
  • Parse CSV with std.csv and decide which cells are numbers by a written rule, not by a parser's habits
  • Compute count, mean, median, minimum and maximum per column from the nearest double of each cell
  • Measure the processor time of a million-row run and remove the cost that dominates it

Before you start

On this page

csvstats reads a CSV file and prints, for every column with numbers, its count, mean, median, minimum and maximum with two decimals, and how many cells it skipped. Two rules decide more than the code does: which cells are numbers, and how a cell becomes a double. Read the cell 0.045 with D’s to!double and the report prints 0.05; read it with C’s strtod, as csvstats does, and it prints 0.04, as Python does.

This project builds the tool with dub and tests its modules, compares three readers on fourteen awkward cells, measures reading on 10,000 cells that sit on a rounding tie, and times a million rows against a budget of 2 seconds of processor time. The practice builds the same report again in Python, one step at a time.

The specification fixes every line of the report

Here is the sample file of this project. It has the cells that real exports contain: an empty one, n/a, a quoted note with a comma, and a temperature written with a space in front of it:

weather.csv, the sample file CSV · data/weather.csv
station,hour,celsius,rain_mm,note
Pune,6,21.5,0.0,
Pune,12,32.5,0.0,clear
Nagpur,6,24.0,1.25,
Nagpur,12,38.5,0.0,hot
Nashik,6,19.5,n/a,sensor down
Nashik,12,29.0,4.75,
Solapur,6, 23.0,0.5,"rain, light"
Solapur,12,36.0,,

What csvstats prints for it is fixed before any code is written, so that two programs written from these rules, in any language, print the same bytes:

  1. The first line is the header. Each column gets one line of the report, in the header’s order.
  2. A cell counts when, taken exactly as it is, it is a number by rule 3 and its value fits a double. Every other cell of the column, an empty one too, is skipped and counted.
  3. A number is an optional + or -, then digits with an optional point and more digits, or a point and digits, then an optional exponent: e or E, an optional sign and digits. Only the ASCII digits 0 to 9 count.
  4. A column with numbers prints count, mean, median, min, max and skipped. The four values have two decimals, from the nearest double of each cell; the mean adds the values in file order, and the median of an even count is the two middle values added and halved.
  5. A column without numbers prints no numbers and the cells it skipped.
  6. No file name gives a usage line and exit status: The whole number a program returns to whatever started it: 0 for success by convention, anything else for a failure. Shells and build pipelines read it to decide what happens next. More in “Project: a marks report in Object Pascal” 2, a file that cannot be read exit status 1, and a record with another number of cells than the header exit status 2, each with a message on standard error: The second output stream every program has, meant for error messages. Kept apart from standard output, it lets a report be saved to a file while its errors still reach the terminal or a log. More in “Project: a marks report in Object Pascal”.

Step through one run on weather.csv, to the celsius line:

Interactive · stage by stage From weather.csv to the celsius line of the report Step through one run of csvstats on weather.csv, and the two places where a bad file stops it.
From weather.csv to the celsius line of the report, step 7 of 7. Showing Read the file, Split into records, Is the cell a number? (" 23.0": skipped), The nearest double, Collect per column (celsius: 7 numbers, 1 skipped), Five statistics. In focus: Five statistics.Read the filereadText in app.dSplit into recordscsvReader with its headerIs the cell a number?" 23.0": skippedThe nearest doublestrtod in cells.dCollect per columncelsius: 7 numbers, 1 skippedFive statisticstwo decimals, in stats.d

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Read the file. app.d reads weather.csv into one string with readText. A missing file stops here: csvstats printed "No such file or directory" on standard error and exited with status 1.
  2. Records and header. csvReader splits the text into records of cells and takes the first line as the header, so station, hour, celsius, rain_mm and note name the five columns. "rain, light" stays one cell because it is quoted.
  3. A record that does not fit. In broken.csv one record has two cells under a header of three. csvReader throws a CSVException, and csvstats printed the row and the lengths and exited with status 2.
  4. The rule. Each cell of a record meets isNumber, which takes the cell as it is. 21.5 passes; " 23.0" fails on its leading space, and the celsius column counts it as skipped.
  5. The value. A cell that passes is read by C's strtod into the nearest double. A value too large for a double, such as 1e400, comes back as NaN and is skipped as well.
  6. Collected. report.d appends the value to its column's list, in file order. After the last record the celsius list holds seven numbers and one skipped cell.
  7. The report line. summarise gives count 7, mean 28.71, median 29.00, min 19.50 and max 38.50, each printed with two decimals. A column without a number, such as station, gets a line of its own: no numbers, 8 cells skipped.

Checkpoint

Check: The specification of the report

  1. Question 1 of 2 The celsius column of weather.csv has eight cells, one of them " 23.0" with a space in front. What does csvstats count?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 7 numbers and 1 skipped cell

    csvstats printed celsius: count 7, mean 28.71, median 29.00, min 19.50, max 38.50, skipped 1. The skipped count is how a padded export gets noticed.

  2. Question 2 of 2 What does csvstats print for the note column of weather.csv, whose eight cells are words or empty?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: note: no numbers, 8 cells skipped

    The report has one line per column of the header. Columns without numbers, station and note here, print "no numbers" and the cells they skipped.

Each module has one job and its own tests

csvstats is a dub project of four files. app.d holds main, which only checks the arguments, reads the file and turns each kind of error into a message and an exit status. The work is in three modules under source/csvstats/, and each of them has a unittest block:

app.d imports csvstats.report, which imports csvstats.cells and csvstats.stats; the last three have unittest blocks.source/app.dmain: the file,errors, exit statuscsvstats.reportrecords, columns,the report linescsvstats.cellsisNumber, toNumber+ unittestcsvstats.statssummarise, twoDecimals+ unittestimportsimportsimports

The four files of csvstats and what each imports

Text description of the diagram

The diagram is read from top to bottom.

  1. source/app.d holds main: it checks the arguments, reads the file and turns each kind of error into a message and an exit status. dub test leaves this file out.
  2. csvstats.report, in source/csvstats/report.d, reads the CSV text with csvReader, sorts the cells into columns and builds the report's lines. It imports the two modules below it.
  3. csvstats.cells holds isNumber, the rule for a number, and toNumber, which reads a cell into the nearest double; its unittest block tests the rule on cells such as " 12" and 1_000.
  4. csvstats.stats holds summarise, the five statistics, and twoDecimals; its unittest block checks them on small lists.

report.d is the heart: it reads the records with csvReader, sorts the cells into one list of numbers and one count of skipped cells per column, and builds the lines:

csvstats.report: records to report lines D · csvstats/source/csvstats/report.d
module csvstats.report;

import std.csv : csvReader;
import std.format : format;
import csvstats.cells : isNumber, toNumber;
import csvstats.stats : summarise, twoDecimals;

/// The report of a CSV text whose first line is the header: one line per column, in the header's order.
string[] report(string text)
{
    auto records = csvReader!string(text, null);
    auto header = records.header;
    auto numbers = new double[][](header.length);
    auto skipped = new size_t[](header.length);
    foreach (record; records)
    {
        size_t column = 0;
        foreach (cell; record)
        {
            immutable value = isNumber(cell) ? toNumber(cell) : double.nan;
            if (value == value)          // false only for NaN: not a number, or too large
                numbers[column] ~= value;
            else
                skipped[column]++;
            column++;
        }
    }
    string[] lines;
    foreach (i, name; header)
    {
        if (numbers[i].length == 0)
        {
            lines ~= format("%s: no numbers, %s cells skipped", name, skipped[i]);
            continue;
        }
        immutable s = summarise(numbers[i]);
        lines ~= format("%s: count %s, mean %s, median %s, min %s, max %s, skipped %s", name, s.count,
            twoDecimals(s.mean), twoDecimals(s.median), twoDecimals(s.min), twoDecimals(s.max), skipped[i]);
    }
    return lines;
}

unittest
{
    immutable text = "city,marks\nPune,72\nNagpur,n/a\nNashik,61\n";
    assert(report(text) == [
        "city: no numbers, 3 cells skipped",
        "marks: count 2, mean 66.50, median 66.50, min 61.00, max 72.00, skipped 1",
    ]);
}
csvstats built, tested and run on three files Python · project_session.py
from session import run

# csvstats as a dub project: its files and recipe, its unit tests, a release build, the reports of two sample files
# and the three ways it can refuse to run.
run("""
cd csvstats
find source -name '*.d' | sort
cat dub.json
dub test
dub build --build=release
./csvstats ../data/weather.csv
./csvstats ../data/halves.csv
!./csvstats ../data/broken.csv
!./csvstats missing.csv
!./csvstats
""")

Output

$ cd csvstats
$ find source -name '*.d' | sort
source/app.d
source/csvstats/cells.d
source/csvstats/report.d
source/csvstats/stats.d
$ cat dub.json
{
	"name": "csvstats",
	"description": "Statistics for each numeric column of a CSV file.",
	"license": "BSL-1.0",
	"targetType": "executable"
}
$ dub test
             Generating test runner configuration 'csvstats-test-application' for 'application' (executable).
     Warning Excluding main source file source/app.d from test.
    Starting Performing "unittest" build using …/bin/ldc2 for aarch64, arm_hardfloat.
    Building csvstats ~master: building configuration [csvstats-test-application]
     Linking csvstats-test-application
     Running csvstats-test-application
3 modules passed unittests
$ dub build --build=release
    Starting Performing "release" build using …/bin/ldc2 for aarch64, arm_hardfloat.
    Building csvstats ~master: building configuration [application]
     Linking csvstats
$ ./csvstats ../data/weather.csv
station: no numbers, 8 cells skipped
hour: count 8, mean 9.00, median 9.00, min 6.00, max 12.00, skipped 0
celsius: count 7, mean 28.71, median 29.00, min 19.50, max 38.50, skipped 1
rain_mm: count 6, mean 1.08, median 0.25, min 0.00, max 4.75, skipped 2
note: no numbers, 8 cells skipped
$ ./csvstats ../data/halves.csv
name: no numbers, 2 cells skipped
maths: count 2, mean 0.12, median 0.12, min 0.12, max 0.12, skipped 0
physics: count 2, mean 1.84, median 1.84, min 1.00, max 2.67, skipped 0
$ ./csvstats ../data/broken.csv
csvstats: ../data/broken.csv: Row 2's length 2 does not match previous length of 3.
[exit status 2]
$ ./csvstats missing.csv
csvstats: missing.csv: No such file or directory
[exit status 1]
$ ./csvstats
usage: csvstats FILE.csv
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 project_session.py

dub test reported 3 modules passed unittests, one for each module with tests. This is the layout the first lesson of this mini-course arrived at: dub test leaves the file with main out, so nothing that needs a test lives there. The two reports match the specification line by line: celsius counted 7 of its 8 cells and skipped " 23.0", rain_mm skipped n/a and the empty cell, and station and note printed no numbers. The three refusals ended with the exit statuses of rule 6, so a script that runs csvstats can tell a missing file, status 1, from a bad file or a wrong call, status 2.

CSV Editor & Viewer Open your own CSV file in the browser and look for padded, empty and quoted cells before csvstats reads it.

Checkpoint

Check: Modules, unittest blocks and exit statuses

  1. Question 1 of 2 dub test printed "3 modules passed unittests" for csvstats. Which modules are they?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: csvstats.cells, csvstats.stats and csvstats.report

    The layout follows the first lesson's rule: a small main in app.d, and everything worth testing in modules with unittest blocks, which dub test runs and counts.

  2. Question 2 of 2 A script runs csvstats on a file that does not exist. Which exit status does it get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1, with "No such file or directory" on standard error

    Rule 6 gives each kind of error its exit status, so a script can tell them apart: 1 for a file that cannot be read, 2 for a malformed CSV or a missing argument.

A written rule decides what a number is

Rule 3 is code of its own, in cells.d, and it does not ask any parser:

csvstats.cells: the rule, and the nearest double D · csvstats/source/csvstats/cells.d
module csvstats.cells;

import core.stdc.stdlib : strtod;
import std.ascii : isDigit;
import std.math : isFinite;
import std.string : toStringz;

/// True when cell, taken exactly as it is, is a number by the specification: an optional + or -, digits with an
/// optional point and more digits (or a point and digits), then an optional exponent; ASCII digits only, nothing else.
bool isNumber(const(char)[] cell) @safe pure nothrow @nogc
{
    size_t i = 0;
    if (i < cell.length && (cell[i] == '+' || cell[i] == '-'))
        i++;
    size_t digits = 0;
    while (i < cell.length && isDigit(cell[i]))
    {
        i++;
        digits++;
    }
    if (i < cell.length && cell[i] == '.')
    {
        i++;
        while (i < cell.length && isDigit(cell[i]))
        {
            i++;
            digits++;
        }
    }
    if (digits == 0)
        return false;
    if (i < cell.length && (cell[i] == 'e' || cell[i] == 'E'))
    {
        i++;
        if (i < cell.length && (cell[i] == '+' || cell[i] == '-'))
            i++;
        immutable start = i;
        while (i < cell.length && isDigit(cell[i]))
            i++;
        if (i == start)
            return false;
    }
    return i == cell.length;
}

/// The nearest double to a cell that isNumber accepts, read by C's strtod; NaN when it does not fit a double.
double toNumber(const(char)[] cell) @trusted
{
    char[64] buffer = void;
    const(char)* text;
    if (cell.length < buffer.length)
    {
        buffer[0 .. cell.length] = cell[];
        buffer[cell.length] = '\0';
        text = buffer.ptr;
    }
    else
        text = cell.toStringz;
    immutable value = strtod(text, null);
    return isFinite(value) ? value : double.nan;
}

unittest
{
    import std.format : format;

    foreach (cell; ["12", "-0.25", "+7", "1e3", "2.5E-3", ".5", "5."])
        assert(isNumber(cell), cell);
    foreach (cell; ["", " 12", "12 ", "1,000", "1_000", "nan", "inf", "0x1A", "1e", "-", ".", "١٢"])
        assert(!isNumber(cell), cell);
    assert(format("%.2f", toNumber("1.005")) == "1.00");    // the nearest double is just below 1.005
    assert(toNumber("1e400") != toNumber("1e400"));         // too large for a double: NaN
}

Why not let to!double decide, and count a cell as skipped when it throws? Because each reader has its own idea of a number, and none of them is the specification. The session gives fourteen cells to to!double, to csvstats and to Python’s float():

Three readers on the same fourteen cells Python · cells_session.py
from session import run

# What three readers make of the same fourteen cells: D's to!double, the rule csvstats uses, and Python's float().
run("""
cd cells
ldc2 -I../csvstats/source cellrules.d ../csvstats/source/csvstats/cells.d
./cellrules
python3 cellrules.py
""")

Output

$ cd cells
$ ldc2 -I../csvstats/source cellrules.d ../csvstats/source/csvstats/cells.d
$ ./cellrules
[12]       to!double: 12        csvstats: number
[-0.25]    to!double: -0.25     csvstats: number
[1e3]      to!double: 1000      csvstats: number
[.5]       to!double: 0.5       csvstats: number
[ 12]      to!double: refused   csvstats: skipped
[12 ]      to!double: refused   csvstats: skipped
[1,000]    to!double: refused   csvstats: skipped
[1_000]    to!double: 1000      csvstats: skipped
[nan]      to!double: nan       csvstats: skipped
[Infinity] to!double: refused   csvstats: skipped
[0x1Ap0]   to!double: 26        csvstats: skipped
[१२]       to!double: refused   csvstats: skipped
[]         to!double: refused   csvstats: skipped
[1e400]    to!double: refused   csvstats: skipped
$ python3 cellrules.py
[12]       float(): 12.0
[-0.25]    float(): -0.25
[1e3]      float(): 1000.0
[.5]       float(): 0.5
[ 12]      float(): 12.0
[12 ]      float(): 12.0
[1,000]    float(): refused
[1_000]    float(): 1000.0
[nan]      float(): nan
[Infinity] float(): inf
[0x1Ap0]   float(): refused
[१२]       float(): 12.0
[]         float(): refused
[1e400]    float(): inf

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cells_session.py

Cell to!double csvstats Python’s float()
" 12" and "12 " refused skipped 12.0
1_000 1000 skipped 1000.0
nan nan skipped nan
Infinity refused skipped inf
0x1Ap0 26 skipped refused
१२, Devanagari 12 refused skipped 12.0
1e400 refused skipped inf

Each reader follows rules of its own, and every row is a way for two programs to disagree about one file: a version of csvstats in Python built on float() would count a padded " 23.0" and a Devanagari १२ that the D version skips. A rule that is written down, and coded the same way in both, removes the question. It also makes cheap work of the common case: a cell such as Pune fails the rule at its first letter, without an exception.

Letting the parser be the specification

cell.to!double inside try looks like a number check, but it accepts 1_000, 0x1Ap0 and nan, which no column of weights or marks should contain, and it throws for every word. Write the rule, test it on the cells you expect to meet, and convert only what it accepts.

Checkpoint

Check: A written rule for what a number is

  1. Question 1 of 2 A column of weights contains the cell 1_000. What did to!double, csvstats and Python's float() make of it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: to!double and float() read 1000; csvstats skipped it

    Each parser has its own idea of a number. A rule written in the specification, coded the same way in every version of the tool, decides the same cells everywhere.

  2. Question 2 of 2 A Python version of csvstats checks cells with float(). On a file with the cell १२, Devanagari twelve, how do its report and the D report differ?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The Python version counts the cell as 12; csvstats skips it, so count and skipped differ by one

    This is why the twin in the practice uses the written rule, not float() alone: the two versions must decide the same cells for a comparison of their reports to mean anything.

Read each cell into the nearest double

A cell that passes the rule still has to become a double, and the report prints two decimals of that double, so the exact double matters. The next session reads the 10,000 cells 0.005, 0.015, … 99.995, each exactly halfway between two values of two decimals, once with to!double and once with C’s strtod, which D calls directly from core.stdc.stdlib:

ties.d: 10,000 cells on a rounding tie, read two ways D · digits/ties.d
// The 10,000 cells 0.005, 0.015, … 99.995, each exactly halfway between two values of two decimals. Each is read
// with to!double and with C's strtod and printed with two decimals, as csvstats prints; the SHA-256 of strtod's
// printed values is compared with Python's in the next command.
import core.stdc.stdlib : strtod;
import std.conv : to;
import std.digest : LetterCase, toHexString;
import std.digest.sha : sha256Of;
import std.format : format;
import std.stdio;
import std.string : toStringz;

void main()
{
    size_t differ;
    string printed;
    foreach (i; 0 .. 10_000)
    {
        immutable thousandths = 5 + 10 * i;
        immutable cell = format("%d.%03d", thousandths / 1000, thousandths % 1000);
        immutable byTo = format("%.2f", cell.to!double);
        immutable byStrtod = format("%.2f", strtod(cell.toStringz, null));
        if (byTo != byStrtod)
            differ++;
        printed ~= byStrtod ~ "\n";
    }
    writeln("real.sizeof = ", real.sizeof, ", double.sizeof = ", double.sizeof);
    writeln("cells from 0.005 to 99.995: 10000");
    writeln("printed differently by to!double and strtod: ", differ);
    writeln("SHA-256 of strtod's printed values: ", toHexString!(LetterCase.lower)(sha256Of(printed))[0 .. 16]);
    writefln("0.045 by to!double: %.20f, printed %.2f", "0.045".to!double, "0.045".to!double);
    writefln("0.045 by strtod:    %.20f, printed %.2f", strtod("0.045", null), strtod("0.045", null));
}
to!double, strtod and Python on the same 10,000 cells Python · digits_session.py
from session import run

# to!double against C's strtod on 10,000 cells that sit exactly halfway between two values of two decimals, and
# Python's float() on the same cells, compared through the SHA-256 of what each prints. ties.d also prints the sizes
# of D's real and double on this target.
run("""
cd digits
ldc2 -O ties.d
./ties
python3 ties.py
""")

Output

$ cd digits
$ ldc2 -O ties.d
$ ./ties
real.sizeof = 8, double.sizeof = 8
cells from 0.005 to 99.995: 10000
printed differently by to!double and strtod: 3937
SHA-256 of strtod's printed values: 4adb942f88333594
0.045 by to!double: 0.04500000000000000527, printed 0.05
0.045 by strtod:    0.04499999999999999833, printed 0.04
$ python3 ties.py
SHA-256 of Python's printed values: 4adb942f88333594
0.045 by float():   0.04499999999999999833, printed 0.04

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 digits_session.py

to!double and strtod gave different two-decimal results for 3,937 of the 10,000 cells. 0.045 shows why: no double is exactly 0.045, and the nearest one, 0.04499999999999999833, lies just below the tie, so it prints as 0.04. strtod returned that double, while to!double returned the next one up, 0.04500000000000000527, which prints as 0.05. Python’s float() read every cell as strtod did: the SHA-256 of its 10,000 printed values begins 4adb942f88333594, exactly as strtod’s does. So csvstats checks a cell with the rule and then reads it with strtod, and its Python twin, reading with float(), prints the same digits.

Measured on this machine

The 3,937 come from LDC 1.43.0 on a Mac with Apple silicon, where D’s real is 8 bytes, the size of a double, as the session printed. Repeat the session on every platform you ship csvstats for before you trust to!double with decimals there.

The consequences show in halves.csv: 1.005 and 2.675 printed as 1.00 and 2.67, because their nearest doubles lie just below the tie, and 0.125, which a double holds exactly, printed as 0.12, the even digit. That is a property of binary doubles, not of D: a spreadsheet that rounds the decimal text would print 1.01 and 2.68.

Key idea

A report that prints decimals depends on how text becomes a double: read with a function that gives the nearest double, such as C’s strtod, and check it against an independent reader on cells that sit on a tie.

Checkpoint

Check: Reading cells into the nearest double

  1. Question 1 of 2 The cell 0.045 is the maximum of a column. What does csvstats print for max, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0.04, because the nearest double to 0.045 lies just below it

    No double is exactly 0.045. The report prints two decimals of the nearest double, which here is below the tie, so 0.04; a reader that returns the double above the tie prints 0.05.

  2. Question 2 of 2 How did the session show that strtod and Python's float() read all 10,000 tie cells the same way?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The SHA-256 of their printed values began with the same 16 hex digits, 4adb942f88333594

    Comparing a hash of all results is a quick way to compare two long outputs exactly. strtod gives the nearest double, as Python's float() does, so csvstats and its Python twin print the same digits.

A million rows in under two seconds of processor time

The budget is 1,000,000 rows within 2 seconds of processor time: The time a processor spent running a program: user, in the program's own code, and sys, in the system's code on its behalf, as /usr/bin/time -p reports them. Unlike real, the time on the clock, it hardly grows when other programs keep the machine busy. on the machine that records the session. The session generates two files, of 100,000 and 1,000,000 rows, builds csvstats and the first reading loop of the tool, throwing.d, which sent every cell through to!double and caught the exception, and times each run with /usr/bin/time -p:

throwing.d: the first reading loop, with an exception per text cell D · speed/throwing.d
// The first reading loop of csvstats: every cell goes through to!double, and a cell that is not a number costs
// an exception, which the loop catches to count the cell as skipped.
import std.conv : to;
import std.csv : csvReader;
import std.file : readText;
import std.stdio;

void main(string[] args)
{
    auto records = csvReader!string(readText(args[1]), null);
    auto header = records.header;
    auto numbers = new double[][](header.length);
    auto skipped = new size_t[](header.length);
    foreach (record; records)
    {
        size_t column = 0;
        foreach (cell; record)
        {
            try
                numbers[column] ~= cell.to!double;
            catch (Exception)
                skipped[column]++;
            column++;
        }
    }
    foreach (i, name; header)
        writefln("%s: %s numbers, %s skipped", name, numbers[i].length, skipped[i]);
}
Two reading loops timed on 100,000 and 1,000,000 rows Python · speed_session.py
from session import run

# A release build of csvstats and of the first, throwing reading loop; generated files of 100,000 and 1,000,000 rows;
# then /usr/bin/time -p for each run: real is the time on the clock, user and sys the processor time of the program.
run("""
cd csvstats
dub build --build=release --quiet
cd ../speed
ldc2 -O gen.d
ldc2 -O --release throwing.d
./gen 100000 > small.csv
./gen 1000000 > big.csv
wc -l < big.csv
/usr/bin/time -p ./throwing small.csv
/usr/bin/time -p ../csvstats/csvstats small.csv
/usr/bin/time -p ../csvstats/csvstats big.csv
""")

Output

$ cd csvstats
$ dub build --build=release --quiet
$ cd ../speed
$ ldc2 -O gen.d
$ ldc2 -O --release throwing.d
$ ./gen 100000 > small.csv
$ ./gen 1000000 > big.csv
$ wc -l < big.csv
 1000001
$ /usr/bin/time -p ./throwing small.csv
station: 0 numbers, 100000 skipped
hour: 100000 numbers, 0 skipped
celsius: 100000 numbers, 0 skipped
rain_mm: 100000 numbers, 0 skipped
note: 0 numbers, 100000 skipped
real 4.97
user 2.17
sys 0.03
$ /usr/bin/time -p ../csvstats/csvstats small.csv
station: no numbers, 100000 cells skipped
hour: count 100000, mean 11.50, median 11.00, min 0.00, max 23.00, skipped 0
celsius: count 100000, mean 27.45, median 27.40, min 15.00, max 39.90, skipped 0
rain_mm: count 100000, mean 19.71, median 19.53, min 0.00, max 39.99, skipped 0
note: no numbers, 100000 cells skipped
real 0.34
user 0.08
sys 0.00
$ /usr/bin/time -p ../csvstats/csvstats big.csv
station: no numbers, 1000000 cells skipped
hour: count 1000000, mean 11.50, median 11.00, min 0.00, max 23.00, skipped 0
celsius: count 1000000, mean 27.45, median 27.40, min 15.00, max 39.90, skipped 0
rain_mm: count 1000000, mean 19.71, median 19.52, min 0.00, max 39.99, skipped 0
note: no numbers, 1000000 cells skipped
real 2.21
user 0.83
sys 0.03

This output changes from run to run: the times depend on the computer and on what else it is doing, so they change from run to run; the reports do not

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 speed_session.py

Run Rows real, on the clock user + sys, processor
throwing.d 100,000 4.97 s 2.20 s
csvstats 100,000 0.34 s 0.08 s
csvstats 1,000,000 2.21 s 0.86 s

The first loop threw 200,000 exceptions for 100,000 rows, one for each station cell and one for each note cell. csvstats did all of that loop’s other work, and more, in 0.08 s of processor time for the same rows, so the exceptions took nearly all of the 2.20 s: about (2.20 − 0.08) ÷ 200,000 ≈ 10.6 µs each. For the million rows csvstats needed 0.86 s, inside the budget. The clock said more, 2.21 s, because the machine that recorded the session was busy with other work; processor time counts only the program’s own work, which is why the budget uses it.

An exception for what should not happen

A word in a text column is expected, so it is a case to test for, not an exception to catch. Keep exceptions for what should not happen, such as a missing file or a malformed row, where csvstats stops anyway.

Checkpoint

Check: Processor time and the cost of exceptions

  1. Question 1 of 2 throwing.d read 100,000 rows of five columns, two of them text. How many exceptions did it throw?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 200,000: one for each station cell and one for each note cell

    An exception per expected word is the cost that dominated the first loop: its processor time was 2.20 s for 100,000 rows, against 0.08 s for csvstats on the same rows.

  2. Question 2 of 2 Why is the budget of 2 seconds measured as user + sys rather than real?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: user and sys count only the program's own work; real also counts waiting while the machine is busy

    A budget must measure the program, not the machine's other work. user plus sys is the processor time the program used, which hardly changes when other programs keep the machine busy.

Practise: csvstats again, in Python

A second implementation written from the specification is the best test oracle: A second, trusted way to work out the expected results of tests, such as a short program written straight from the specification, used to check an implementation on many inputs. for the first: wherever the two disagree, one of them is wrong, or the specification is unclear. The lab builds csvstats’ report in Python in three steps, the number rule, the statistics and the report, and every sample test is a line that the D tool printed in this lesson’s sessions:

Code lab · 3 steps

Build csvstats again, in Python

Write the number rule, the five statistics and the whole report so that the Python version prints exactly what csvstats printed for weather.csv and halves.csv.

  1. Decide which cells are numbers, as csvstats does To do
  2. Compute a column's five statistics, as csvstats does To do
  3. Print the whole csvstats report from a CSV text To do

Exercise · Easy · Python

Decide which cells are numbers, as csvstats does

The first step of a second csvstats, in Python: the rule that decides which cells count. Write two functions in cells.py:

  • is_number(cell): True when the cell, taken exactly as it is, is an optional + or -, then digits with an optional point and more digits, or a point and digits, then an optional exponent: e or E, an optional sign and at least one digit. Only the ASCII digits 0 to 9 count, and nothing else may be in the cell, not even a space.
  • to_number(cell): the cell as a float when is_number accepts it and the value is finite; otherwise None. A cell such as 1e400 follows the rule but is too large for a float, so it gives None.

The starter trusts float(), which accepts " 12", "1_000", "Infinity" and Devanagari digits, as the lesson's session showed. The sample tests are the fourteen cells of that session, with csvstats' verdict on each.

Starter code · cells.py

def is_number(cell):
    """True when csvstats counts the cell as a number."""
    try:
        float(cell)
        return True
    except ValueError:
        return False


def to_number(cell):
    """The cell's value, or None when csvstats skips it."""
    return float(cell) if is_number(cell) else None
The sample tests · test_cells.py
from cells import is_number, to_number

COUNTED = ["12", "-0.25", "1e3", ".5"]
SKIPPED = [" 12", "12 ", "1,000", "1_000", "nan", "Infinity", "0x1Ap0", "१२", "", "1e400"]


def test_the_cells_csvstats_counts():
    """the four cells that cellrules printed as number"""
    assert [is_number(c) for c in COUNTED] == [True] * 4
    assert [to_number(c) for c in COUNTED] == [12.0, -0.25, 1000.0, 0.5]


def test_the_cells_csvstats_skips():
    """the ten cells that cellrules printed as skipped"""
    assert [to_number(c) for c in SKIPPED] == [None] * 10


def test_spaces_and_underscores_are_not_numbers():
    """float() accepts them; the rule does not"""
    assert not is_number(" 12") and not is_number("12 ") and not is_number("1_000")


def test_digits_of_other_scripts():
    """१२ is Devanagari twelve: float() reads it, csvstats skips it"""
    assert not is_number("१२")
    assert not is_number("١٢")


def test_the_rest_of_the_rule():
    """signs, a point on either side, and exponents with their digits"""
    assert all(is_number(c) for c in ["+7", "5.", "2.5E-3", "1e+3", "007"])
    assert not any(is_number(c) for c in ["-", ".", "+.", "1e", "e3", "1.2.3", "1e3.5", "--1"])


def test_too_large_for_a_float():
    """1e400 follows the rule, but its value does not fit"""
    assert is_number("1e400")
    assert to_number("1e400") is None
    assert to_number("1e308") == 1e308
A hint

Walk through the cell with an index, as cells.d does: skip one sign, count the digits before and after an optional point, and give up when there are none. c in "0123456789" tests an ASCII digit; str.isdigit() also accepts other scripts' digits. Then float() is safe to call, and math.isfinite tells a value that fits.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Easy · Python

Compute a column's five statistics, as csvstats does

The second step: the statistics of one column. Write two functions in summary.py, exactly as stats.d computes them, so that the two programs agree to the last digit:

  • summarise(values): for a non-empty list of floats in file order, the tuple (count, mean, median, smallest, largest). The mean adds the values one by one in file order and divides by the count. The median is the middle value of the sorted values, or for an even count the two middle values added and divided by 2. Leave the caller's list as it was.
  • two(value): the value with two decimals, as csvstats prints it: two(28.714285714285715) is "28.71".

The starter uses statistics.fmean and sorts the caller's list in place. The sample tests replay the columns of weather.csv and halves.csv from the lesson's session, where 0.125 is printed as 0.12 and 1.005 as 1.00.

Starter code · summary.py

import statistics


def summarise(values):
    """(count, mean, median, smallest, largest) of a column's numbers, as stats.d computes them."""
    values.sort()
    n = len(values)
    median = values[n // 2]
    return (n, statistics.fmean(values), median, values[0], values[-1])


def two(value):
    """The value as csvstats prints it."""
    return str(round(value, 2))
The sample tests · test_summary.py
from summary import summarise, two

CELSIUS = [21.5, 32.5, 24.0, 38.5, 19.5, 29.0, 36.0]     # weather.csv, the 7 cells csvstats counted
RAIN = [0.0, 0.0, 1.25, 0.0, 4.75, 0.5]                  # weather.csv, the 6 cells it counted


def printed(values):
    count, mean, median, smallest, largest = summarise(values)
    return (count, two(mean), two(median), two(smallest), two(largest))


def test_celsius():
    """csvstats: celsius: count 7, mean 28.71, median 29.00, min 19.50, max 38.50"""
    assert printed(CELSIUS) == (7, "28.71", "29.00", "19.50", "38.50")


def test_rain_has_an_even_count():
    """csvstats: rain_mm: count 6, mean 1.08, median 0.25, min 0.00, max 4.75"""
    assert printed(RAIN) == (6, "1.08", "0.25", "0.00", "4.75")


def test_halves_print_from_the_nearest_double():
    """csvstats: maths 0.12 everywhere; physics mean 1.84, min 1.00, max 2.67"""
    assert printed([0.125, 0.125]) == (2, "0.12", "0.12", "0.12", "0.12")
    assert printed([1.005, 2.675]) == (2, "1.84", "1.84", "1.00", "2.67")


def test_the_callers_list_is_left_alone():
    """summarise sorts a copy, as stats.d does with .dup"""
    values = list(CELSIUS)
    summarise(values)
    assert values == CELSIUS


def test_the_mean_adds_in_file_order():
    """0.1 + 0.2 + 0.3 in this order, divided by 3"""
    count, mean, median, smallest, largest = summarise([0.1, 0.2, 0.3])
    assert mean == (0.1 + 0.2 + 0.3) / 3
    assert (count, median, smallest, largest) == (3, 0.2, 0.1, 0.3)


def test_one_value():
    """a column with a single number"""
    assert printed([7.5]) == (1, "7.50", "7.50", "7.50", "7.50")
A hint

Use total = 0.0 and a loop for the mean, and sorted(values) for a sorted copy. For two, an f-string with :.2f rounds the exact value of the float, as D's format("%.2f", …) does, so it prints 1.005 as 1.00.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Print the whole csvstats report from a CSV text

The last step puts the first two together. Write report(text) in report.py: it takes the text of a CSV file whose first line is the header and returns the report's lines, one per column in the header's order, exactly as csvstats prints them:

  • "<name>: count <n>, mean <m>, median <md>, min <lo>, max <hi>, skipped <s>" for a column with at least one number, the four values with two decimals and <s> the cells it skipped;
  • "<name>: no numbers, <s> cells skipped" for a column without one.

Read the text with Python's csv module, so that a quoted field such as "rain, light" stays one cell. The starter holds the first attempts of the earlier steps and splits lines at commas: copy your to_number and summarise over them, then fix report. The sample tests are the reports that csvstats printed for weather.csv and halves.csv.

Starter code · report.py

import statistics


def to_number(cell):
    """Step 1: the cell's value, or None when csvstats skips it."""
    try:
        return float(cell)
    except ValueError:
        return None


def summarise(values):
    """Step 2: (count, mean, median, smallest, largest)."""
    values.sort()
    n = len(values)
    return (n, statistics.fmean(values), values[n // 2], values[0], values[-1])


def report(text):
    """The lines csvstats prints for a CSV text with a header line."""
    lines = text.splitlines()
    header = lines[0].split(",")
    numbers = [[] for _ in header]
    skipped = [0 for _ in header]
    for line in lines[1:]:
        for i, cell in enumerate(line.split(",")):
            value = to_number(cell)
            if value is None:
                skipped[i] += 1
            else:
                numbers[i].append(value)
    out = []
    for i, name in enumerate(header):
        if not numbers[i]:
            out.append(f"{name}: no numbers, {skipped[i]} cells skipped")
        else:
            n, mean, median, lo, hi = summarise(numbers[i])
            out.append(f"{name}: count {n}, mean {mean:.2f}, median {median:.2f}, min {lo:.2f}, max {hi:.2f}, skipped {skipped[i]}")
    return out
The sample tests · test_report.py
from report import report

WEATHER = """station,hour,celsius,rain_mm,note
Pune,6,21.5,0.0,
Pune,12,32.5,0.0,clear
Nagpur,6,24.0,1.25,
Nagpur,12,38.5,0.0,hot
Nashik,6,19.5,n/a,sensor down
Nashik,12,29.0,4.75,
Solapur,6, 23.0,0.5,"rain, light"
Solapur,12,36.0,,
"""

HALVES = """name,maths,physics
Asha,0.125,1.005
Ravi,0.125,2.675
"""


def test_weather():
    """the five lines csvstats printed for weather.csv"""
    assert report(WEATHER) == [
        "station: no numbers, 8 cells skipped",
        "hour: count 8, mean 9.00, median 9.00, min 6.00, max 12.00, skipped 0",
        "celsius: count 7, mean 28.71, median 29.00, min 19.50, max 38.50, skipped 1",
        "rain_mm: count 6, mean 1.08, median 0.25, min 0.00, max 4.75, skipped 2",
        "note: no numbers, 8 cells skipped",
    ]


def test_halves():
    """the three lines csvstats printed for halves.csv"""
    assert report(HALVES) == [
        "name: no numbers, 2 cells skipped",
        "maths: count 2, mean 0.12, median 0.12, min 0.12, max 0.12, skipped 0",
        "physics: count 2, mean 1.84, median 1.84, min 1.00, max 2.67, skipped 0",
    ]


def test_a_header_without_records():
    """every column has no numbers and nothing skipped"""
    assert report("a,b\n") == ["a: no numbers, 0 cells skipped", "b: no numbers, 0 cells skipped"]


def test_the_rule_decides():
    """1_000, Infinity and a padded 7 are skipped; 1e3 counts"""
    assert report("x\n1_000\nInfinity\n 7\n1e3\n") == ["x: count 1, mean 1000.00, median 1000.00, min 1000.00, max 1000.00, skipped 3"]
A hint

rows = list(csv.reader(io.StringIO(text))) gives the header as rows[0] and the records after it. Keep one list of numbers and one count of skipped cells per column, filled as you go through each record with enumerate.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the self-check, on the five ideas of the project. The idea your answers show least is asked first, and every answer comes with its reason:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Two people write csvstats, one in D and one in Python. What makes their reports agree byte for byte?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: A specification that fixes the order, the number rule, the reading of each cell and the format of each line

    The project's six rules decide every question two implementations could answer differently, which is what lets the practice build a Python version that prints what csvstats printed.

  2. Question 2 of 10 The rain_mm column counted 0.0, 0.0, 1.25, 0.0, 4.75 and 0.5. What median did csvstats print?

    Type a number.

    A hint

    Sort the six values and take the third and the fourth.

    Show the answer: Question 2 of 10

    Answer: 0.25 (anything from 0.249 to 0.251 counts)

    Sorted: 0.0, 0.0, 0.0, 0.5, 1.25, 4.75. With six values, the median is the two middle ones added and halved: (0.0 + 0.5) / 2 = 0.25, printed as 0.25.

  3. Question 3 of 10 Why does app.d hold only the arguments, the file and the errors?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: dub test leaves the main file out, so code there would never be unit tested

    With the work in modules that have unittest blocks, dub test runs every test, and "3 modules passed unittests" shows that they ran.

  4. Question 4 of 10 broken.csv has a record with 2 cells under a header of 3. What did csvstats do?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It printed the row and both lengths on standard error and exited with status 2

    std.csv's reader throws when a record's length does not match, and csvstats reports it as a bad file, status 2, with the reader's message.

  5. Question 5 of 10 Which of these cells does csvstats count as numbers?

    Choose every answer that is right.

    Show the answer: Question 5 of 10

    Answer:

    • .5
    • 1e3

    The rule: optional sign, digits with an optional fraction or a point and digits, optional exponent, ASCII digits only, nothing else, and a value that fits a double.

  6. Question 6 of 10 Besides deciding the same cells in every language, what else does checking the rule first give csvstats?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Speed: a word fails at its first letter, without an exception

    The rule makes the common case cheap: most text cells are rejected at their first character, which is why csvstats read 100,000 rows in 0.08 s of processor time.

  7. Question 7 of 10 halves.csv has the cells 1.005 and 2.675. What did csvstats print for min and max?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: min 1.00 and max 2.67

    1.005 and 2.675 have no exact double; their nearest doubles are slightly smaller, so their two-decimal forms are 1.00 and 2.67, in D with strtod and in Python alike.

  8. Question 8 of 10 The maths column has 0.125 twice, a value a double holds exactly. Why did the report print 0.12, not 0.13?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: An exact half is printed to the even digit, and 2 is even

    Two cases look alike: 1.005 has no exact double and its nearest double is below the tie, while 0.125 is exact, so the tie is real and goes to the even digit, 0.12.

  9. Question 9 of 10 Which numbers of /usr/bin/time -p does the budget of 2 seconds compare with?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: user and sys added together

    Processor time, user plus sys, was 0.86 s for the million rows on the machine that recorded the session, inside the budget.

  10. Question 10 of 10 Where does csvstats still use exceptions, and why there?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: For a missing file and a malformed CSV, which should not happen and end the run

    An expected case, a word in a text column, is tested for; an exceptional one, a file that is missing or broken, is thrown and caught once in main.

Interview questions

Warm-up (fresher to mid-level): Why does csvstats test a cell with its own rule before converting it, instead of calling to!double and catching the exception?

For two reasons, correctness and cost. to!double has its own idea of a number: it accepted 1_000, 0x1Ap0 and nan and refused " 12", and Python’s float() would accept other cells again, so letting a parser decide makes the report depend on the language. A rule written in the specification and coded the same way in every version decides the same cells everywhere. The cost is the second reason: an exception for every word is expensive, and in the lesson the throwing loop spent 2.20 s of processor time on 100,000 rows, where csvstats needed 0.08 s, because a word fails the rule at its first letter. Exceptions stay for what should not happen, such as a missing file.

Two longer questions, on a report that a spreadsheet seems to contradict and on a tool that is slow on a big export:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    A user says csvstats is wrong: for a column whose largest cell is 1.005 it prints max 1.00, while their spreadsheet shows 1.01. Is csvstats wrong? How do you answer, and what, if anything, do you change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    csvstats reads a million rows of the sample format in under a second of processor time, but a customer's export of two million rows takes minutes and much memory. How do you find out why, and what would you look at first in a D tool built like csvstats?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does csvstats print for a column of numbers, and for one without?

    count, mean, median, min, max and the cells it skipped, with two decimals on the four values. A column with no numbers prints no numbers and how many cells it skipped.

  2. Why does csvstats skip the cell " 23.0" in the celsius column?

    A cell counts only if, exactly as written, it matches the number rule, and a leading space is not part of a number. Python's float() would have taken it as 23.0.

  3. Which exit statuses does csvstats use?

    2 when no file is named or a record has another number of cells than the header, 1 when the file cannot be read, each with a message on standard error; 0 with the report.

  4. Which cells does D's to!double accept that the csvstats rule skips?

    1_000, nan and 0x1Ap0. It refuses padded cells, Infinity, Devanagari digits and 1e400, all of which Python's float() accepts, so neither parser can be the specification.

  5. Why does csvstats convert a cell such as 0.045 with C's strtod and not with to!double?

    strtod gives the nearest double, just under the tie, which prints as 0.04, as Python's float() does. to!double gave the next double up, 0.05, and the two differed on 3,937 of 10,000 tie cells.

  6. Why does csvstats print 1.00 for the cell 1.005 and 0.12 for 0.125?

    It prints two decimals of the nearest double. The double for 1.005 lies just below the tie, and 0.125 is held exactly, so its half goes to the even digit. A spreadsheet rounding the text would show 1.01.

  7. Where did the first reading loop, throwing.d, spend its time on 100,000 rows?

    Almost all of its 2.20 s of processor time went on 200,000 exceptions, about 10.6 µs each; csvstats did the rest of the work in 0.08 s.

  8. Why is the speed budget of csvstats set in processor time rather than clock time?

    User plus sys time counts only the program's own work, while the clock also counts whatever else the machine does. A million rows took 0.86 s of processor time and 2.21 s on the clock.

Key takeaways

  • A specification written before the code fixes the header order, the number rule, the five statistics, the two decimals and the exit statuses, so two programs written from it print the same bytes.
  • csvstats keeps main small and puts the rule, the statistics and the report in modules with unittest blocks, which dub test ran: 3 modules passed unittests.
  • A written rule decides what a number is: to!double and Python’s float() disagree on padded cells, Infinity, 0x1Ap0, Devanagari digits and 1e400, both accept 1_000 and nan, and the rule skips them all.
  • The report prints two decimals of the nearest double: to!double differed from strtod on 3,937 of 10,000 tie cells, while strtod and Python’s float() agreed on all of them.
  • An exception per text cell dominated the first loop’s time; with the rule, a million rows took 0.86 s of processor time, inside the budget of 2 s.

Terms in this lesson

processor time
The time a processor spent running a program: user, in the program's own code, and sys, in the system's code on its behalf, as /usr/bin/time -p reports them. Unlike real, the time on the clock, it hardly grows when other programs keep the machine busy.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.