Procedure
A procedure (also called a proc) is a named, callable block of code that performs a task. It can receive data through an argument list, and it can return one or more values. A procedure that returns nothing returns void. Procedures are declared with ::, making them compile-time constants.
welcome :: () {
print("Welcome aboard!\n");
}
triple :: (x: int) -> int {
return x * 3;
}
main :: () {
welcome(); // Welcome aboard!
print("% \n", triple(9)); // 27
}
The general form of a procedure declaration is:
name :: (arg1: Type1, arg2: Type2) -> ReturnType {
// body
return ...;
}
If a procedure does not return a value, the -> and return type can be omitted entirely. Each argument must have its type specified individually. The order of procedure declarations at the top level does not matter; a procedure can be called before the point where it is declared.
Return
The return keyword exits the current procedure immediately, breaking out of all inner scopes. It can optionally pass back one or more values. Statements after a return in the same block are not executed.
floor_at_zero :: (x: int) -> int {
if x < 0 return 0;
return x;
}
Calling a Procedure
A procedure is called by writing its name followed by parentheses containing the parameters. The number and types of parameters must match the argument list, unless default arguments are used.
multiply :: (a: int, b: int) -> int { return a * b; }
main :: () {
result := multiply(3, 4);
print("result is %\n", result); // result is 12
// A procedure call can itself be a parameter:
print("nested: %\n", multiply(multiply(2, 3), multiply(4, 5))); // nested: 120
}
Named Arguments
When calling a procedure, you can pass parameters by argument name using =. This lets you supply arguments in any order and makes calls with many parameters more readable. Once you use a named argument in a call, all subsequent arguments must also be named.
create_window :: (x: float, y: float, width: float, height: float) -> Window {
// ...
}
w := create_window(width = 800, height = 600, x = 50, y = 100);
Default Arguments
A procedure argument can be given a default value. When a call does not provide a parameter for that argument, the default is used. Default values can be literals, variables, or even other procedure calls.
launch :: (altitude := 0.0, heading := 0.0, thrust := 100) {
print("Launching at alt %, heading %, thrust %\n", altitude, heading, thrust);
}
main :: () {
launch(); // Launching at alt 0, heading 0, thrust 100
launch(500.0); // Launching at alt 500, heading 0, thrust 100
launch(500.0, 90.0); // Launching at alt 500, heading 90, thrust 100
launch(thrust = 50); // Launching at alt 0, heading 0, thrust 50
launch(200.0, thrust = 75); // Launching at alt 200, heading 0, thrust 75
}
When arguments have default values, the number of parameters in a call can be smaller than the number of arguments. If you want to skip an argument that has a default and provide a later one, you must use named arguments.
Multiple Return Values
A procedure can return more than one value. The return types are listed after ->, separated by commas. At the call site, the returned values are assigned to a matching number of variables. It is not required to capture every return value.
divide :: (a: int, b: int) -> int, int {
return a / b, a % b;
}
main :: () {
quotient, remainder := divide(17, 5);
print("quotient: %, remainder: %\n", quotient, remainder); // quotient: 3, remainder: 2
just_quotient := divide(17, 5); // Second return value is discarded.
}
Named and Default Return Values
Return values can be given names and default values. Named return values serve as documentation. Default return values are used when the procedure does not explicitly provide a value for them in a return statement.
fetch :: (key: string) -> data: int = -1, exists: bool = false {
if key == "secret" return 42, true;
return; // Returns the defaults: -1, false
}
main :: () {
d, ok := fetch("secret");
print("d: %, ok: %\n", d, ok); // d: 42, ok: true
d2, ok2 := fetch("missing");
print("d: %, ok: %\n", d2, ok2); // d: -1, ok: false
}
The _ Identifier
Use _ to explicitly discard a return value you do not need. _ does not need to be declared; it always exists.
_, exists := fetch("secret");
Passing by Copy vs. Passing by Pointer
By default, arguments of 8 bytes or fewer (such as int, bool, float, or any pointer) are passed by value. The procedure receives a copy, and changes to the copy do not affect the original. Larger types may be passed by reference internally for performance, but the procedure still cannot modify the original.
To allow a procedure to modify a variable, pass a pointer to it explicitly:
clear :: (value: *int) {
value.* = 0;
}
main :: () {
health := 75;
clear(*health);
print("health is %\n", health); // health is 0
}
The same applies to structs. Struct arguments are immutable inside a procedure. If you need to modify a struct, pass a pointer to it:
Point :: struct { x, y: float; }
translate :: (p: *Point, dx: float, dy: float) {
p.x += dx;
p.y += dy;
}
main :: () {
cursor := Point.{10.0, 20.0};
translate(*cursor, 5.0, -3.0);
print("cursor is %\n", cursor); // cursor is {15, 17}
}
If you need a mutable copy of a struct argument without modifying the original, make a local copy inside the procedure:
unit_direction :: (p: Point) -> Point {
result := p;
len := sqrt(result.x * result.x + result.y * result.y);
result.x /= len;
result.y /= len;
return result;
}
Local Procedures
Procedures can be declared inside other procedures. A local (or inner) procedure is only visible within the scope of the enclosing procedure. This is useful for helper functions that have no purpose outside their enclosing context.
analyze :: () {
cube :: (n: int) -> int { return n * n * n; }
print("% \n", cube(4)); // 64
}
main :: () {
analyze();
// cube(4); // Error: Undeclared identifier 'cube'.
}
Local procedures have two important restrictions:
- An inner procedure cannot access variables from its enclosing procedure's stack frame. Attempting to do so produces an error: Attempt to use a variable from an outer stack frame. (Closures are not supported.)
- A local procedure must be declared before it is called within a procedure body. Forward references to constant declarations inside procedure bodies are not allowed.
wrapper :: () {
val := 10;
helper :: () {
// print("%\n", val); // Error: closures are not supported.
}
helper();
}
Procedure Type and Address
Every procedure has an address in memory and a type determined by its signature. You can print a procedure's address, query its type with type_of, and store it in a variable of the matching procedure type:
multiply :: (a: int, b: int) -> int { return a * b; }
main :: () {
print("address: %\n", multiply); // procedure 0x7ff...
print("type: %\n", type_of(multiply)); // procedure (s64, s64) -> s64
op: (int, int) -> int = multiply;
print("result: %\n", op(6, 7)); // result: 42
}
Procedure pointers (also called function pointers) can be passed as arguments to other procedures, stored in arrays, and used as callbacks.
Lambda Expressions
A lambda is a short-hand syntax for defining a procedure inline. The => arrow creates a one-expression body whose result is implicitly returned. Types on the arguments can be omitted when they can be inferred.
subtract :: (a, b) => a - b;
main :: () {
print("%\n", subtract(10, 3)); // 7
}
Anonymous Procedures
A procedure does not need a name. An anonymous procedure can be called immediately at its definition site, or assigned to a variable for later use:
main :: () {
// Immediately invoked:
result := (x: int, y: int) -> int {
return x + y;
}(15, 27);
print("result is %\n", result); // result is 42
// Assigned to a variable:
shout := () {
print("Hey!\n");
};
shout(); // Hey!
}
Overloading
Multiple procedures can share the same name as long as their argument lists differ in type. The compiler selects the best match based on the types of the parameters passed at the call site:
show :: (n: int) { print("An integer: %\n", n); }
show :: (s: string) { print("A string: %\n", s); }
show :: (f: float) { print("A float: %\n", f); }
main :: () {
show(99); // An integer: 99
show("world"); // A string: world
show(2.718); // A float: 2.718
}
Two overloads on the same scope level cannot have identical argument lists. When overloads exist at different scope levels (for example, one global and one local), the compiler considers all of them and picks the best fit. If a local overload fits exactly, it is preferred.
Inlining
Inlining replaces a procedure call with the procedure's body at the call site, eliminating the overhead of the call. Jai gives you explicit control over this with the inline and no_inline keywords. These are not hints; the compiler will obey them.
There are two places to specify inlining:
- At the declaration: place
inlineorno_inlinebefore the argument list. - At the call site: place
inlineorno_inlinebefore the call expression.
quick_negate :: inline (x: int) -> int {
return -x;
}
heavy_compute :: (x: int) -> int { return x * x * x; }
main :: () {
a := quick_negate(7); // Inlined: declared as inline.
b := no_inline quick_negate(7); // Not inlined: overridden at call site.
c := heavy_compute(3); // Normal call.
d := inline heavy_compute(3); // Inlined: overridden at call site.
}
Recursion
A recursive procedure is one that calls itself. Every recursive procedure must have a base case that stops the recursion; otherwise the program will exhaust the stack and crash with a stack overflow.
factorial :: (n: int) -> int {
if n <= 1 return 1;
return n * factorial(n - 1);
}
main :: () {
print("10! = %\n", factorial(10)); // 10! = 3628800
}
The #this Directive
#this refers to the current procedure as a compile-time constant. It can be used in place of the procedure's name inside its own body. This is occasionally useful in recursive procedures or when you want a reference to the enclosing procedure without hard-coding its name:
blast_off :: (n: int) {
if n <= 0 return;
print("% ", n);
#this(n - 1);
}
main :: () {
blast_off(5); // 5 4 3 2 1
print("#this is %\n", #this); // prints the address of main
}
Autocast with xx
If a parameter's type does not match the argument type, you can prefix the parameter with xx to auto-cast it. The value is cast to the expected type at the call site:
set_opacity :: (level: float) {
print("opacity: %\n", level);
}
main :: () {
val: int = 75;
// set_opacity(val); // Error: type mismatch.
set_opacity(xx val); // OK: val is auto-cast to float.
}
Using with Procedure Arguments
Placing using on a struct pointer argument brings that struct's fields into the procedure body's scope. This provides a style similar to methods in other languages. See using for more details.
Actor :: struct {
x, y: float;
label: string;
}
nudge_right :: (using a: *Actor, amount: float) {
x += amount;
print("% moved to x=%\n", label, x);
}
main :: () {
hero := Actor.{0, 0, "Hero"};
nudge_right(*hero, 5.0); // Hero moved to x=5
}
The #deprecated Directive
Marking a procedure with #deprecated causes the compiler to emit a warning whenever it is called. An optional message string tells the caller what to use instead:
old_export :: () #deprecated "Use write_to_file() instead." {
// ...
}
write_to_file :: () {
// ...
}
main :: () {
old_export();
// Warning: This procedure is deprecated. Note: "Use write_to_file() instead."
}
Polymorphic Procedures
A procedure can be made generic by marking one or more of its arguments with $. This tells the compiler to capture information from the call site, such as a type or a constant value, and use it elsewhere in the procedure's signature or body. The procedure is then specialized for each unique combination of captured values.
identity :: (x: $T) -> T {
return x;
}
main :: () {
a := identity(42); // T is captured as s64.
b := identity("hello"); // T is captured as string.
c := identity(3.14); // T is captured as float.
}
Here $T on the first argument captures the type of whatever is passed in, and the same T is then reused as the return type. The compiler generates a separate version of identity for each distinct type it is called with.
The same mechanism works for capturing values, not just types. This is most often used to capture array sizes:
sum_array :: (values: [$N] int) -> int {
total := 0;
for values total += it;
return total;
}
main :: () {
nums := int.[10, 20, 30, 40];
print("sum is %\n", sum_array(nums)); // sum is 100
}
Accepting Polymorphic Structs
Procedures often need to accept a polymorphic struct. Because a polymorphic struct is not itself a type but a blueprint, there are several syntaxes for writing such a procedure, each with a different level of strictness.
Implicit Polymorphism
The simplest form is to write the bare blueprint name as the argument type, with no $ and no parameter list. The compiler infers the parameters from the call site, and you access them through the argument using dot syntax:
Box :: struct (T: Type, N: int) {
items: [N] T;
}
first_item :: (b: Box) -> b.T {
return b.items[0];
}
main :: () {
b: Box(int, 4);
b.items[0] = 99;
print("first is %\n", first_item(b)); // first is 99
}
This is the most concise form and is preferred when the procedure simply needs to read or pass through the parameters without binding them to its own names.
Capturing Parameters Explicitly
If you need to refer to the captured parameters by name elsewhere in the signature, list them with $ in the argument's parameter list:
fill :: (b: *Box($T, $N), value: T) {
for 0..N-1 b.items[it] = value;
}
main :: () {
b: Box(float, 3);
fill(*b, 1.5);
print("b is %\n", b); // b is {[1.5, 1.5, 1.5]}
}
Here $T and $N are bound to the corresponding parameters of the passed-in Box, and they can then be used as the type of value and as a loop bound.
Partial Matching: Mixing Concrete and Polymorphic Parameters
You don't have to make every parameter of a polymorphic struct generic. You can fix some parameters to concrete values and leave others open with $, in any combination. The rule is straightforward: anything marked with $ is captured from the call site, and anything without $ is a concrete value that the argument's type must match exactly.
Box :: struct (T: Type, N: int) {
items: [N] T;
}
// Accepts any Box where N is exactly 2; T is inferred.
proc_pair :: (b: Box($T, 2)) {
print("A Box of 2 items of type %\n", T);
}
// Accepts any Box of ints; N is inferred.
proc_ints :: (b: Box(int, $N)) {
print("A Box of % ints\n", N);
}
// Accepts only Box(int, 2). Not polymorphic at all.
proc_exact :: (b: Box(int, 2)) {
print("Exactly Box(int, 2)\n");
}
The compiler matches each call against the signature, accepting only arguments whose type fits the pattern:
a: Box(float, 2);
b: Box(int, 5);
c: Box(int, 2);
proc_pair(a); // OK: T inferred as float.
proc_pair(c); // OK: T inferred as int.
// proc_pair(b); // Error: N is 5, not 2.
proc_ints(b); // OK: N inferred as 5.
proc_ints(c); // OK: N inferred as 2.
// proc_ints(a); // Error: T is float, not int.
proc_exact(c); // OK.
// proc_exact(a); // Error: wrong T.
// proc_exact(b); // Error: wrong N.
This is distinct from #bake_arguments, which produces a new partially-specialized type alias for use in declarations. Inline partial matching is for procedure signatures that need to accept a family of related types and infer the remaining parameters at the call site. The two can also be combined: you can build a partially-baked blueprint with #bake_arguments and then accept it with further $ parameters in a procedure signature.
The $T/Base Syntax
Sometimes you want a procedure to accept any struct that is a particular polymorphic struct, or that contains one through using. The $T/Base syntax does exactly that: it binds T to the full type of the argument, while requiring that the argument either be an instance of Base or have one embedded in it.
describe :: (b: $T/Box) {
print("type is %, holds % items of type %\n", T, b.N, b.T);
}
Tagged_Box :: struct {
using inner: Box(string, 2);
label: string;
}
main :: () {
a: Box(int, 5);
describe(a); // type is Box(T=s64, N=5)...
t: Tagged_Box;
describe(t); // type is Tagged_Box, holds 2 items of type string
}
Without the /Box constraint, the procedure would accept any type at all. With it, only types that are or contain a Box are allowed.
The $T/interface Syntax
A looser form of constraint accepts any struct that has the same fields as a given reference struct, regardless of whether it is related to that struct by composition. This is similar to traits or interfaces in other languages and provides a form of structural typing:
Named :: struct {
name: string;
}
greet :: (x: $T/interface Named) {
print("Hello, %\n", x.name);
}
Player :: struct {
name: string;
score: int;
}
main :: () {
p := Player.{name = "Ada", score = 1200};
greet(p); // Hello, Ada
}
Player has no relationship to Named, but because it has a field called name of the same type, it satisfies the interface and is accepted.
Choosing a Form
- Use implicit polymorphism (
b: Box) when you only need to read the parameters via dot syntax. - Use explicit capture (
b: Box($T, $N)) when you need to refer to the parameters by name in the rest of the signature. - Use partial matching (
b: Box($T, 2)orb: Box(int, $N)) when you want to accept only a subset of instantiations, fixing some parameters and inferring others. - Use $T/Base when you want to also accept structs that embed the polymorphic struct via
using. - Use $T/interface when you want to accept any struct shaped like a given reference, with no requirement that it contain that reference.
Reflection on Procedures
You can inspect a procedure's argument types and return types at runtime using Jai's type introspection system. Cast the result of type_info(type_of(proc)) to *Type_Info_Procedure to access the argument_types and return_types arrays:
multiply :: (a: int, b: int) -> int { return a * b; }
main :: () {
info := cast(*Type_Info_Procedure) type_info(type_of(multiply));
print("kind: %\n", info.type); // PROCEDURE
print("arguments: ");
for info.argument_types print("% ", <<it);>
The #procedure_name Directive
#procedure_name() returns the name of the current procedure as a string, known at compile time. This is useful for logging and diagnostics:
run_task :: () {
print("Inside %\n", #procedure_name()); // Inside run_task
}