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.
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