David Brandt · trains of thought

Programming languages · The lowest common denominator, part 3 of 4

Where the languages disagree

Division, rounding, null, and the empty string that Oracle thinks is nothing

Seeded 2001 Written November 2026

Levels decide which languages have to agree. They don't make them agree.

Take any two languages that both carry level 1 and write the same innocent-looking expression in each. Surprisingly often you get two answers. Each answer is reasonable, each is documented, and each has been relied on by somebody for years. None of them is wrong. They are just different, and a rule that gives different answers in different places is exactly what LCD existed to prevent.

So inside each level there is one more test, applied to every feature: do all the targets that carry this level give it the same meaning? If they do, it's in. If they can with help, it's in, and a small runtime library supplies the help where it's missing. If they can't agree, LCD has two options. It can pick one meaning and say so out loud, in the name of the operation. Or it can leave the feature out.

Most of LCD's character would have come from that last case. Here is how.

Dividing two integers

C, C++ and Java truncate: 7 divided by 2 is 3. Pascal keeps the two apart: div for integer division, and /, which always gives a real, 3.5. PL/SQL's / gives 3.5 too. Perl gives 3.5 as a floating-point number. Ada truncates, but distinguishes rem from mod, which disagree as soon as a negative number turns up.

LCD's answer: no / on integers at all. Division says how it rounds: divide_truncated, divide_floor, divide_exact, the last of which fails if anything is left over. It's longer to type. Nobody ever has to wonder.

Rounding

Java's BigDecimal offers every rounding mode you could want. Oracle's ROUND rounds half away from zero, as does COBOL's ROUNDED. Delphi's Round rounds half to even, the banker's way. C leaves it to you.

So rounding names its mode too: round_half_even, round_half_away_from_zero. Where a target lacks the mode you asked for, the runtime library implements it in that target's own language. That's why the level 1 example in part 2 chose half away from zero: the database and COBOL could both do it without help.

Null

In SQL, NULL means unknown, and unknown spreads. Compare a null with anything and the answer is unknown, and an IF whose condition is unknown quietly takes the ELSE branch. Java and Pascal have null references, which do something much louder. C has null pointers, which do whatever they like.

LCD would have had no null. If something can be absent, its type says so, and the code has to say what happens in each case. Where LCD code meets its host, the transpiled code checks every value on the way in, so a NULL from the database becomes a failure with a name, rather than a quiet wrong turn.

The empty string

Oracle stores the empty string as NULL. This is not a bug. It is how Oracle has always worked, and it's documented, and it has surprised people for decades.

In LCD, text is never absent, although it can be empty. The PL/SQL runtime would have had to represent empty text in a way that could never turn into NULL on the way through.

What counts as true

C has no boolean type of its own and treats zero as false. Many BASICs use -1 for true. Perl treats "0" and "" as false, along with a few other things you have to learn the hard way.

In LCD only a boolean can decide an if. There is no truthiness to translate, so there is nothing to get wrong.

Overflow

Signed overflow in C is undefined, which means the compiler may assume it never happens. Java wraps around without a word. PL/SQL and Ada raise an error. Pascal raises one only if overflow checks are switched on. COBOL quietly cuts off the digits that don't fit, unless you asked for ON SIZE ERROR.

In LCD, overflow is a failure, everywhere. The C runtime checks for it, so that C behaves like the others.

Where strings start

PL/SQL's SUBSTR, COBOL and Pascal's strings start counting at 1. C, C++ and Java start at 0.

LCD has to pick one and transpile it for the others. I'd have picked 1, because business people count from 1. In Y I ended up with something better: every index carries its unit, so a position can't be confused with a count.

Names

PL/SQL, Pascal, Ada and COBOL don't care about case. PL/SQL names stopped at 30 characters, and so did COBOL's. COBOL wants hyphens where everyone else wants underscores. And every language reserves a different set of words.

So every LCD name is lowercase with underscores, at most 30 characters long, unique even if you ignore case, and not a reserved word anywhere. COBOL gets hyphens instead of underscores. Y still has the all-lowercase rule today. I like to think it came from somewhere.

Errors

C returns error codes. C++, Java, Pascal, Ada and PL/SQL have exceptions, all different. MFC throws pointers to exceptions, which you then have to delete. COBOL has status fields.

An LCD function declares, by name, the ways in which it can fail. Each target then reports those failures in its own way: a return code in C, an exception in Java, a raised error in PL/SQL.

The rest

Two more fall out quickly. C and C++ leave the order in which a function's arguments are evaluated unspecified, so an LCD expression may have at most one effect. And every language has its own precedence table, so LCD requires parentheses wherever two kinds of operator meet.

The last one is about time and chance. Every target has its own clock and its own random number generator, and no two of them agree. Business logic in LCD would have had neither. "Today" would be an argument, passed in from outside. So would any random seed. Given the same inputs, every target would compute the same answer, which is the whole point.

Memory

LCD's memory was managed: Java's garbage collector, or reference counting in C++. Making that work the same everywhere is its own puzzle, because the targets manage memory in three different ways. Java collects garbage. C++, Delphi and Perl count references. PL/SQL copies, and shares nothing.

The levels from part 2 sort this out. At levels 0 to 2 there are only values: assign a record and you get your own copy. Behind the scenes a runtime may share it and copy it only when someone writes to it, but no program can tell. That works everywhere, including PL/SQL.

Level 3 adds values that really are shared. For those to behave identically under a garbage collector and under reference counting, two things have to go. Cycles go, because a reference counter can't reclaim a cycle and a garbage collector can. Finalisers go, because nobody knows when a garbage collector will run, but everybody knows exactly when the last reference disappears.

One rule, three languages

Here is a level 2 rule, with a loop in it, which PL/SQL can carry and plain SQL can't. Again, the notation borrows from Y.

module invoicing
level structured


-- A line on an invoice.
record invoice_line:
    quantity is integer
    unit_price is decimal(12, 2)


-- Why an invoice cannot be totalled.
failure invoice_error:
    no_lines
    negative_quantity


-- Totals an invoice, with the volume discount the price list gives above 1000.00.
--   why: The shop, the web site and the nightly batch must charge the same amount for the same lines.
function invoice_total(lines is list of invoice_line) returns decimal(12, 2) fails invoice_error:
    if no_lines = lines.is_empty():
        fail invoice_error.no_lines
    else has_lines:
        var total is decimal(12, 2) = 0.00

        for line in lines:
            if negative = (line.quantity < 0):
                fail invoice_error.negative_quantity
            else counted:
                total := total + (line.unit_price * line.quantity)

        if large_order = (total >= 1000.00):
            return round_half_even(total * 0.95, places: 2)
        else small_order:
            return total

Every condition has a name, large_order or small_order, and so does every way of failing. That is how you would check that the shop, the web site and the nightly batch took the same path: the names would be the same strings in every language's logs.

Here it is in PL/SQL. Oracle has no half-to-even rounding, so that call goes to the runtime library, lcd_std:

CREATE OR REPLACE PACKAGE BODY invoicing AS
  FUNCTION invoice_total(lines IN invoice_lines) RETURN NUMBER IS
    total NUMBER(12, 2) := 0;
  BEGIN
    IF lines IS NULL OR lines.COUNT = 0 THEN
      lcd_std.fail('invoicing.invoice_error.no_lines');
    END IF;

    FOR i IN 1 .. lines.COUNT LOOP
      IF lines(i).quantity IS NULL OR lines(i).unit_price IS NULL THEN
        lcd_std.fail('lcd.missing_value');
      END IF;
      IF lines(i).quantity < 0 THEN
        lcd_std.fail('invoicing.invoice_error.negative_quantity');
      END IF;
      total := total + (lines(i).unit_price * lines(i).quantity);
    END LOOP;

    IF total >= 1000.00 THEN
      RETURN lcd_std.round_half_even(total * 0.95, 2);
    ELSE
      RETURN total;
    END IF;
  END invoice_total;
END invoicing;

All those IS NULL checks are the price of LCD having no null. Leave them out, and a null quantity coming in from outside would make every comparison unknown and quietly take the wrong branch.

And in Java as it was in 2001, before generics and before enums:

public static BigDecimal invoice_total(List lines) throws invoice_error {
    if (lcd_std.require(lines).isEmpty()) {
        throw new invoice_error(invoice_error.no_lines);
    }
    BigDecimal total = lcd_std.decimal("0.00");
    for (Iterator it = lines.iterator(); it.hasNext(); ) {
        invoice_line line = (invoice_line) it.next();
        if (line.quantity < 0) {
            throw new invoice_error(invoice_error.negative_quantity);
        }
        total = total.add(line.unit_price.multiply(BigDecimal.valueOf(line.quantity)));
    }
    if (total.compareTo(lcd_std.decimal("1000.00")) >= 0) {
        return lcd_std.round_half_even(total.multiply(lcd_std.decimal("0.95")), 2);
    }
    return total;
}

Yes, that is invoice_total and not invoiceTotal. It's unidiomatic Java, and it's deliberate. A search for the rule's name should find it in every language.

The C version returns its failure as a code and writes the total through a pointer, and its money is a scaled integer from the runtime library. The Delphi version gets off lightly: Delphi's Currency type holds four fixed decimal places, so the arithmetic is native.

The library

The standard library would have been small, and written once per target, by hand, in that target's own language: text, numbers with their named roundings and divisions, dates with a named rule for what happens at the end of a month, collections, and conversion to and from text in an explicit format, never one that depends on the machine's locale.

What it would never have had: files, the network, the database, threads, the clock, randomness, anything visual. That all belongs to the program around the rules.

The tests

A test would be written once in LCD and transpiled along with the code. It would run on every target that carries the module's level, and the targets would have to agree. When they didn't, that would not mean one of them won. It would mean a transpiler or a runtime library was wrong.

In the end the tests would be the real definition of each level. A feature is in a level when its tests pass on every target of that level.

All of which sounds tidy on paper. Part 4 is about what it would have cost to build, and why that, rather than any of the problems above, is what killed it.

David Brandt is a computer scientist, systems architect and independent scholar based in Reykjavík.