Classes

J2's object system is small and opinionated: classes are values, fields are in scope inside methods, and whether a method can mutate is written in its signature.

A class is a value

A class is created by a class expression and bound to a name like any other value. There is no separate declaration form:

Point = class {
    x = 0.0
    y = 0.0

    dist() = sqrt(x * x + y * y)

    move(dx, dy) := {
        x = x + dx
        y = y + dy
    }

    ::origin() = Point(x: 0, y: 0)
}

p = Point(x: 3, y: 4)
print(p.dist())      # 5
p.move(1, 1)
print(p.x)           # 4

Fields

Fields are declared one per line, in any of four forms:

Config = class {
    retries              # untyped, defaults to null
    limit = 10           # untyped, with a default
    rate: float          # typed, defaults to null
    label: text = "run"  # typed, with a default
}

Type annotations serve the native compiler, exactly as they do for functions: a class whose fields are fully typed can be compiled to a native struct. Behavior is identical either way.

Construction

The class itself is the constructor; calling it makes an instance. Arguments are either all named or all positional, and fields not mentioned take their defaults:

Point = class {
    x = 0.0
    y = 0.0
}

a = Point(x: 3, y: 4)     # named
b = Point(3, 4)           # positional, in declaration order
c = Point(y: 7)           # x keeps its default
print(fmt("{} {} {}", a.x, b.y, c.x))    # 3 4 0

There is no init method to write. When construction needs computation, give the class a static factory:

Celsius = class {
    degrees = 0.0
    ::from_fahrenheit(f) = Celsius(degrees: (f - 32.0) * 5.0 / 9.0)
}

boiling = Celsius::from_fahrenheit(212.0)
print(boiling.degrees)    # 100

Methods and the purity signature

Methods reuse the two binding operators, and this is the heart of the design:

  • A method defined with = is pure. It may read fields but the compiler rejects any attempt to assign to one.
  • A method defined with := is mutating and may update fields.

Inside a method, fields are simply in scope by name; there is no self or this. A method reads as a function over the fields it mentions:

Account = class {
    balance = 0.0

    can_afford(amount) = balance >= amount     # pure: reads only

    deposit(amount) := {                       # mutating: updates a field
        balance = balance + amount
    }
}

acct = Account(balance: 20.0)
acct.deposit(5.0)
print(acct.balance)             # 25
print(acct.can_afford(30.0))    # false

The marker earns its keep at compile time. Because a method written with = is guaranteed never to write a field, the compiler is free to reorder calls to it or run them on different cores without changing what the program means, and that guarantee is exactly what automatic parallelization is built on.

Static members

A member declared with a leading :: belongs to the class rather than to instances, and is reached through the class name with ::. Statics carry factories and class-level helpers:

Temp = class {
    k = 0.0
    ::zero() = Temp(k: 0.0)
    ::from_c(c) = Temp(k: c + 273.15)
}

t = Temp::from_c(25.0)
print(t.k)    # 298.15

Instances are references

Like sequences and maps, instances are shared rather than copied. Two names bound to the same instance see the same mutations, which is what makes an object identity worth having:

Counter = class {
    n = 0
    bump() := { n = n + 1 }
}

c1 = Counter(n: 0)
c2 = c1
c1.bump()
c1.bump()
print(c2.n)    # 2, same object

Conventions

There are no visibility modifiers; a leading underscore on a field or method name marks it as internal by convention. There is no method overloading. Classes stay small in idiomatic J2: data plus the few operations that belong to the data, with free functions doing the rest.

0.1.0

The extends clause is accepted and an instance of a subclass carries the parent's fields, but calls to methods defined only on the parent do not resolve in this release. Prefer composition, or redeclare the needed methods on the subclass, until inheritance lands fully.

Typed classes and native code

When every field and method signature in a class is annotated, the native compiler lowers instances to plain structs and pure methods to plain functions, at which point class-heavy numeric code runs at native speed. Mutating methods keep reference semantics in both engines, so aliased instances behave identically compiled or interpreted:

Vec2 = class {
    x: float
    y: float
    norm() -> float = sqrt(x * x + y * y)
}

func total_norm(vs) = {
    t := 0.0
    for v in vs { t += v.norm() }
    give t
}

vs = [Vec2(3.0, 4.0), Vec2(6.0, 8.0)]
print(total_norm(vs))    # 15