4.16. Optional types
Every language needs a way to say "no value here". Query a database for a row
that does not exist, or read a parameter that was never set, and something has
to come back. Python has None, R has NULL, JSON has null, and C++ solves
it in the library with std::optional<T>.
Morloc’s principle is that sourced functions stay idiomatic, so it needs a mechanism that lowers to each language’s own answer while staying consistent across the boundary. That is what the optional type is for.
4.16.1. Syntax
The ? prefix marks a type as optional, and Null is the absent value. ?Int
is an integer that might be absent, ?Str a string that might be. The prefix
applies to any type, including lists (?[Int]) and records (?Person).
--' Get the first element of a list, or nothing
safeHead :: [Int] -> ?Int
testNull :: ?Int
testNull = Null
|
|
Morloc writes Null capitalized in source, following the convention that
constructors start with an uppercase letter — the same as True and False.
In JSON output it serializes as lowercase null, per the JSON standard.
|
4.16.2. Working with optional values
Functions that produce or consume optionals are sourced like any others:
module main (testSafeHead, testSafeHeadEmpty, testFromNull)
import root-py
safeHead :: [Int] -> ?Int
safeHead xs
? length xs == 0 = Null
: .[0] xs
source Py from "main.py" ("default")
default :: a -> ?a -> a
testSafeHead :: ?Int
testSafeHead = safeHead [10, 20, 30]
testSafeHeadEmpty :: ?Int
testSafeHeadEmpty = safeHead []
testFromNull :: Int
testFromNull = default 0 Null
The Python side handles None the way Python always does:
def default(default_val, x):
if x is None:
return default_val
return x
$ ./main testSafeHead
10
$ ./main testSafeHeadEmpty
$ ./main --keep-null testSafeHeadEmpty
null
$ ./main testFromNull
0
|
|
When an exported function’s top-level result is Null (or ()), the
nexus prints an empty line rather than the literal null. Printing null would
be noisy in a CLI tool, and a downstream consumer that ingested a stray null
line could choke on it or, worse, treat it as a valid record. Pass --keep-null
when you want the literal emitted, as above.
|
The same shape works in the other languages. In C++, using std::optional:
#include <optional>
template <class T>
T orDefault(T default_val, const std::optional<T>& x) {
if(x.has_value()){
return x.value();
} else {
return default_val;
}
}
|
|
The helper is orDefault, not default. default is a C++ keyword,
and a function so named will not compile.
|
And in R, using NULL:
orDefault <- function(default_val, x){
if(is.null(x)){
return(default_val)
} else {
return(x)
}
}
4.16.3. Optional record fields
Record fields may be optional, which is what you want for data with missing or
unknown values. The where form below is an alternative syntax for record
declarations, equivalent to the brace syntax in Records:
record Person where
name :: Str
age :: ?Int
record Py => Person = "dict"
source Py from "foo.py" ("makePerson")
makePerson :: Str -> ?Int -> Person
alice :: Person
alice = makePerson "Alice" 30
bob :: Person
bob = makePerson "Bob" Null
$ ./person alice
{"name":"Alice","age":30}
$ ./person bob
{"name":"Bob","age":null}
4.16.4. Optionals across languages
An optional produced in one language can be consumed in another with no interop code from you:
-- C++ produces an optional value
source Cpp from "foo.hpp" ("cSafeDiv")
cSafeDiv :: Int -> Int -> ?Int
-- Python consumes it
source Py from "foo.py" ("pFromNull")
pFromNull :: Int -> ?Int -> Int
testCppToPy :: Int
testCppToPy = pFromNull (-1) (cSafeDiv 10 3)
testCppToPyNull :: Int
testCppToPyNull = pFromNull (-1) (cSafeDiv 10 0)
$ ./optional testCppToPy
3
$ ./optional testCppToPyNull
-1
The compiler generates the serialization at each boundary. A std::nullopt in
C++ becomes JSON null, which Python reads as None.
4.16.5. Implicit coercion
Morloc coerces a plain value to an optional wherever the context wants one, so you never write a wrapper at the call site:
source Py from "foo.py" ("addOpt")
addOpt :: ?Int -> ?Int -> ?Int
-- both arguments are plain Int, coerced to ?Int
testCoerceAddOpt :: ?Int
testCoerceAddOpt = addOpt 3 4
-- the second argument (42) is Int, coerced to ?Int
testCoerceArg :: Int
testCoerceArg = pFromNull 0 42
$ ./optional testCoerceAddOpt
7
$ ./optional testCoerceArg
42
Coercion crosses language boundaries too. A C++ function returning a plain
Int can feed a Python parameter typed ?Int:
source Cpp from "foo.hpp" ("cAddOne")
cAddOne :: Int -> Int
testCppIntToPyOpt :: Int
testCppIntToPyOpt = pFromNull 0 (cAddOne 41)
$ ./optional testCppIntToPyOpt
42
4.16.6. Nested optionals are idempotent
?(?T) parses and typechecks, but at run time it collapses to a single ?T.
There is one Null, and no way to tell an "outer Null" from an "inner Null".
This is deliberate.
The reason goes back to why ? is a language primitive rather than a library
type like C++'s std::optional. ? must lower to each target language’s own
missing value: None in Python, NULL in R, std::optional<T> in C++. In
Python and R — and in most dynamic languages — that value is structureless.
There is no mechanism for telling an outer None from an inner one; both are
the same singleton. If Morloc allowed two distinguishable null levels, the
semantics would diverge across backends, since C++ could fake it with nested
std::optional and Python could not. That would break the portability ?
exists to provide.
So ?T, ?(?T), and ?(?(?T)) all serialize to the same wire format and the
same runtime representation in every backend:
collapsed1 :: ?(?Int)
collapsed1 = Null
collapsed2 :: ?(?Int)
collapsed2 = 7 -- treated identically to (7 :: ?Int)
$ ./optional collapsed1
$ ./optional collapsed2
7
If you genuinely need layered nullability — telling "the lookup failed" apart from "the lookup succeeded but the field was unset" — encode the distinction in a type of your own:
record LookupResult = LookupResult
{ tableMissing :: Bool -- step 1 failure
, fieldMissing :: Bool -- step 2 failure
, value :: ?Int -- present when both succeeded
}
|
|
Sum types — tagged unions such as data Result = Found Int | Missing — are planned but not yet supported. Their cross-language design is the open
problem, since not every backend has a first-class sum representation.
|