4.4. Integer types

Morloc has one integer type for ordinary use and a family of fixed-width types for when the width matters. This section covers how integers are written, how the default type behaves across languages, and what happens at the boundaries.

4.4.1. Writing integer literals

Integers may be written in decimal, hexadecimal, octal, or binary:

-- standard decimal notation
42

-- hexadecimal notation (case insensitive)
0xf00d
0xDEADBEEF

-- octal notation (upper or lowercase 'o')
0o755

-- binary notation (upper or lowercase 'b')
0b0101

A prefixed literal must contain only digits valid for its base and must end on a non-identifier character. A trailing character that is not a valid digit for the base is a compile-time error, not a silently truncated literal followed by an unrelated identifier:

$ morloc eval -e "0xF00D"
61453
$ morloc eval -e "0xF0OD"
<expr>:1:1: malformed hexadecimal literal: 0xF0OD
$ morloc eval -e "0b1001"
9
$ morloc eval -e "0o755"
493

morloc eval evaluates a single expression, which makes it a good way to check one of these rules. It has no implicit prelude, so anything beyond a bare literal needs an import:

$ morloc eval -e '5 - 1'
<expr>:1:3: error:
Undefined term: -
hint: an eval expression has no implicit prelude; prefix the expression with 'import root-py;' (or the module that defines -) to bring it into scope
$ morloc eval -e 'import root-py; 5 - 1'
4

4.4.2. Integer types at a glance

Type Width Use case

Int

Variable (arbitrary precision)

Default integer for most code. Works across all languages.

I8, I16, I32, I64

8, 16, 32, 64 bits (signed)

Performance-critical code with known bounds.

U8, U16, U32, U64

8, 16, 32, 64 bits (unsigned)

Bit manipulation, byte data, indices.

4.4.3. The default Int type

Int is Morloc’s universal integer, and integer literals are Int unless something says otherwise:

x = 42          -- Int
y = 0xDEADBEEF  -- Int (hex literal)
z = -9999       -- Int

On the wire Int is variable-width: values up to 64 bits fit in 16 bytes inline, and larger values spill to a pointer to an array of 64-bit limbs. But the range you actually get inside a language is whatever that language’s native binding provides:

Language Native binding for Int Representable range

Python

int

Arbitrary precision

C++

int

32-bit signed (-2^31 to 2^31 - 1)

R

integer

32-bit signed

This asymmetry is the thing to remember about Int. A value that a Python pool holds happily may not fit in the C++ or R pool it is handed to. If a field needs more than 32 bits on those backends, declare it I64 or U64, which map to int64_t in C++ and to R’s numeric (53-bit integer precision via double).

4.4.4. Big integers from Python

Python’s integers are arbitrary precision and Morloc’s Int takes full advantage of that. Factorials make the point quickly:

main.loc
module main (fact)

import root-py

fact :: Int -> Int
fact n
  ? n == 0 = 1
  : n * fact (n - 1)
$ morloc make -o calc main.loc
$ ./calc fact 100
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

That is a 525-bit integer, far past any fixed-width type. It is stored as a multi-limb big integer and printed exactly.

4.4.5. Overflow at a language boundary

When a value too large for the target language’s type crosses into it, Morloc raises an error at the boundary rather than truncating silently.

To show this we need to force the computation to happen in Python and then move the result. root-py exports idpy and root-cpp exports idcpp: identity functions pinned to one language. Wrapping a term in idpy forces it into the Python pool, and idcpp then drags the result across into C++. Without them the compiler would collapse fact to pure C++ — faster, but it would not demonstrate anything.

main.loc
module main (factCpp, factR)

import root-py
import root-cpp
import root-r

fact :: Int -> Int
fact n
  ? n == 0 = 1
  : n * fact (n - 1)

factPy :: Int -> Int
factPy n = idpy (fact n)

factCpp :: Int -> Int
factCpp x = idcpp (factPy x)

factR :: Int -> Int
factR x = idr (factPy x)

Small values cross without trouble:

$ ./calc factCpp 5
120

Large ones report where and why they failed:

$ ./calc factCpp 100
Error: run failed
Integer overflow: 9-limb integer (576 bits) does not fit in 32-bit type (range -2147483648 to 2147483647)
  at _ [cpp] (mid=2787, main.loc:16:20)
  at factCpp [cpp] (mid=1, main.loc:1:14)

R is limited to 32-bit integers, and to 53-bit integer precision through doubles, so it refuses the same value:

$ ./calc factR 100
Error: run failed
Integer overflow: 9-limb integer (576 bits) does not fit in R's numeric type (max 2^53 for integer precision).
  at _ [r] (mid=2815, main.loc:19:16)
  at factR [r] (mid=2, main.loc:1:23)

Both report the same shape: what overflowed, what it would not fit in, and the call chain that got there.

4.4.6. Compile-time literal bounds

A literal written into a fixed-width type is bounds-checked against that type:

tooLarge :: U8
tooLarge = 1000

The check happens during code generation, so morloc typecheck passes and morloc make is what rejects it:

$ morloc typecheck intbounds.loc
tooLarge :: U8
$ morloc make intbounds.loc
intbounds.loc:6:12: error:
Integer literal 1000 overflows U8 (range 0 to 255)
  |
6 | tooLarge = 1000
  |            ^

The caret points at the literal, not at the binding name, so when the same literal is referenced from several sites the diagnostic stays on the offending source.

4.4.7. Fixed-width integer types

When values are known to be bounded, fixed-width types map directly onto the target language’s native types:

Morloc type C++ Python R

I8

int8_t

int

integer

I16

int16_t

int

integer

I32

int32_t

int

integer

I64

int64_t

int

numeric (double)

U8

uint8_t

int

raw

U16

uint16_t

int

integer

U32

uint32_t

int

numeric (double)

U64

uint64_t

int

numeric (double)

These serialize directly: the wire format is identical to the in-memory representation, with no conversion step. That makes them the right choice for numerical code and for interop with C libraries that require specific widths.

Note The Python column is int throughout rather than a genuinely fixed-size type such as a numpy scalar. Types can be specialized that way; see Native type mappings, and Tensors and Tables for the higher-performance shared-memory types.

4.4.8. Converting between integer types

Two typeclasses in root cover numeric conversion. into is for conversions that can never fail and never lose information. tryInto is for everything else:

class TotalInto a b where
  into :: a -> b

class PartialInto a b where
  tryInto :: a -> b

Widening is total — signed to wider signed, unsigned to wider unsigned, and unsigned into a strictly wider signed target. A reflexive TotalInto a a instance covers the identity case.

wide :: I8 -> I64
wide x = into x

Anything that can fail goes through tryInto: narrowing, negative into unsigned, or unsigned into a same-or-narrower signed target. Its signature looks total because every instance is a conversion written in a backend language, and it reports a value that does not fit by raising there:

byte :: I32 -> U8
byte x = tryInto x
$ ./bytes byte 65
65
$ ./bytes byte 9999
Error: run failed
value 9999 out of range [0, 255]
  at byte [py] (mid=2, bytes.loc:1:20)

To decide for yourself what an out-of-range value means, wrap the conversion in @try, which turns a raise into a value you can match on — either the converted number or the reason there isn’t one. @try and the Try type it produces are covered in Failure and recovery; the shape is:

byteOrZero :: I32 -> U8
byteOrZero x = match (@try (tryInto x :: U8))
  | (Ok b)  = b
  | (Err _) = 0

byteOrReport :: I32 -> Str
byteOrReport x = match (@try (tryInto x :: U8))
  | (Ok b)  = "fits: #{@show b}"
  | (Err e) = "does not fit: #{e}"
$ ./bytes byteOrZero 65
65
$ ./bytes byteOrZero 9999
0
$ ./bytes byteOrReport 9999
"does not fit: value 9999 out of range [0, 255]"
The `

U8` ascription is doing the work the old signature used to: tryInto is polymorphic in its target, so something has to say which conversion you meant.

Int gets the most restrictive treatment, because its width varies by backend: 32-bit in R and C++, unbounded in Python. Every Int to fixed-width conversion goes through tryInto — even Int → I64 — and converting U32 or wider into Int does too. That keeps behaviour the same everywhere.

4.4.9. Negation and unary minus

The - glyph plays two roles: binary subtraction and unary negation. Which one you get depends on whitespace.

-- prefix `-` on a value: the additive inverse
neg :: Int -> Int
neg x = -x

-- prefix `-` on an expression: parenthesize the expression
shifted :: Int -> Int
shifted x = -(x + 1)

-- works on any numeric primitive (Int, I8..I64, U8..U64,
-- Real, F32, F64) via the `Negatable` typeclass
flipReal :: Real -> Real
flipReal x = -x
Negative literals

A - directly against a digit, with no space between, is part of the literal. So -1 is an atomic integer rather than a function call, and works in places where calls are not allowed, such as pure-data files:

xs :: [Int]
xs = [-1, -2, -3, -100]

ys :: [Real]
ys = [-1.5, -2.0e-3, -0xff]

point :: (Int, Int)
point = (-3, -4)
$ ./neg xs
[-1,-2,-3,-100]
$ ./neg ys
[-1.5,-0.002,-255]
$ ./neg point
[-3,-4]

The same atomic-lexing rule extends to the non-finite Real literals -Inf and -NaN; see Floating-point types.

When - is unary and when it is binary

The lexer uses an asymmetric-whitespace rule. A - immediately followed by a digit is part of a negative literal whenever the dash sits where an expression cannot have just ended:

  • at the start of input;

  • after an opening delimiter ((, [, ,, =, and so on);

  • after another operator;

  • after whitespace, when the digit is not separated from the dash.

Anywhere else — where the dash directly follows a token that finishes an operand, with no whitespace between — it is binary subtraction.

Expression Interpretation

-1

atomic literal -1

f -1

f applied to literal -1 (asymmetric whitespace)

f - 1

binary subtraction f - 1 (symmetric whitespace)

f-1

binary subtraction f - 1 (no whitespace)

[-1, -2]

list of two negative literals

1 + -2

1 + (-2); the -2 is a literal

-(x + 1)

desugars to negate (x + 1)

-x

desugars to negate x

The first row of that table is easy to verify. Applying a number to something is a type error, and that is exactly the error 5 -1 produces — proving the -1 was read as an argument rather than as subtraction:

$ morloc eval -e "5 -1"
<expr>:1:1: error:
General type error: Application of non-functional expression of type: Int
With `f

Int → Int` defined as f x = x * 10, the three spellings behave as the table says:

$ ./dashtest t1     -- t1 = f -1
-10
$ ./dashtest t2     -- t2 = 100 - 1
99
$ ./dashtest t3     -- t3 = 100-1
99
Position restrictions

Prefix - on a non-literal expression is allowed wherever an expression can begin, including on the right of an infix operator. The one restriction is that its operand must start with an atom — an identifier, a literal, an open paren or bracket — and not with another prefix -.

-- ok: -x at the start of an expression
neg1 :: Int -> Int
neg1 x = -x

-- ok: -x on the right of a binary operator
neg2 :: Int -> Int
neg2 x = 1 + -x

-- ok: subtracting a negated value
neg3 :: Int -> Int -> Int
neg3 x y = x - -y

-- ok: -x parenthesized; equivalent to neg2
neg4 :: Int -> Int
neg4 x = 1 + (-x)

-- ok: parenthesize the inner negation to stack two
double :: Int -> Int
double x = -(-x)

Two adjacent prefix dashes are a parse error:

$ morloc typecheck negbad.loc
negbad.loc:6:11: unexpected '-'
    |
  6 | bad x = - -x
    |           ^
The Negatable typeclass

Negation comes from a typeclass in the internal module:

class Negatable a where
  negate :: a -> a

Every numeric primitive has an instance in root-py, root-cpp, and root-r that dispatches to the host language’s native unary minus. The parser desugars -x to negate x, so writing negate x yourself is equivalent. The compiler picks the language for a negation the same way it picks the language for any other polymorphic call: from the imported language modules and the surrounding cross-language boundaries.