-
-
Notifications
You must be signed in to change notification settings - Fork 0
syntax
Goal: In this section, we'll walk you through a complete Hello, ago! demo to help verify your environment setup and understand the basic "compile → run" workflow. This manual assumes you have Java development experience; if you're unfamiliar with Maven or Git, minimal steps are provided below.
| Step | Description | Command / Link |
|---|---|---|
| JDK | ago's compiler and runtime are both based on Java 22; OpenJDK 22 or Oracle JDK 22 is recommended. | https://jdk.java.net/22/ |
| Maven | Used to build the SDK and runtime; minimum version 3.6+. | https://maven.apache.org/download.cgi |
| Git | Download the official source repository. | https://git-scm.com/ |
Tip: If you've already configured JDK in your IDE (IntelliJ, VS Code), subsequent command-line operations can be executed directly in the IDE's Terminal.
# 1) Clone the official repository
git clone https://github.com/siphonlab/ago.git
cd ago
# 2) Compile SDK and runtime (skip unit tests to save time)
mvn clean package -DskipTestsThis generates two jars in
ago-compiler/target/andago-engine/target/:
ago-compiler‑<ver>.jar— compiles .ago source into bytecode.ago-engine‑<ver>.jar— contains the VM and runtime environment for interpretation and execution.
Create a new file hello_world.ago in the project root:
fun main(){
Trace.print("hello world");
}
Notes
mainis the entry function. In ago, classes are first-class citizens, and since functions are also classes, functions are first-class too.Trace.printis an official print function supporting multiple overloads (int, double, Object, etc.); its definition can be found in lang.ago.
Execute in the command line:
# Use the compiler jar as entry point to compile hello_world.ago into bytecode
java -cp target/ago-compiler-<ver>.jar \
-agocp ago-sdk/lang.agopkg \
-i hello_world.agoParameter Explanation
-cp: Add the compiler jar to classpath.-agocp: Specify the ago standard library package (in theago-sdksubdirectory).-i: Source .ago file(s) to compile; multiple files can be passed at once.
After compilation, corresponding bytecode files are generated in the current directory (e.g., the bytecode for
hello_world.agois saved as.agoc; see compiler docs for details).
# Use the runtime jar to start the interpreter, with current directory as script root
java -jar target/ago-engine-1.0-SNAPSHOT.jar \
-agocp ago-sdk/lang.agopkg \
./Parameter Notes
-agocp: Same as during compilation — tells the runtime where to find the standard library../: Specifies the script root directory (containing .ago files). By default it looks for themain#function as the entry point; alternatively use-e functionNameto specify another function.
Output
hello world
If you see the above output, congratulations! Your environment is set up successfully and you can proceed to write more complex ago programs following this workflow.
ago supports basic data types: boolean, byte, short, int, long, float, double, decimal, char, null, string, classref, void, etc.
Different runtimes may map these to different types based on their characteristics. In the Java runtime, all types before string map one-to-one; string maps to String, classref maps to int (the class id), and void maps to null.
These types are not objects. When conversion to an object is needed, use the corresponding boxing type.
Literals are the textual representation of these types in source code. Variable/field declarations can either explicitly state the type or use var for automatic inference.
| Type | Description | Default Value | Example |
|---|---|---|---|
boolean |
true/false | false |
var b = true; |
char |
Single character (Unicode) | '\u0000' |
var ch = c'A'; |
byte |
8-bit integer, range [-128,127] | 0 |
var n = 0x1Fb; |
short |
16-bit integer | 0 |
var s as short = 32767; |
int |
32-bit integer | 0 |
var i = 1234; |
long |
64-bit integer | 0L |
var l = 1234567890123456789L; |
float |
Single-precision floating point | 0.0f |
var f = 3.14f; |
double |
Double-precision floating point | 0.0d |
var d = 1e-3; |
decimal |
Decimal number | 0.0D |
var d = 1.000001D; var d2 as decimal = 22 |
string |
UTF-8 string (immutable) | "" |
var str = "hello"; |
classref |
Reference to a class object (meta-type) | — | var t as classref = Cat; |
void |
Used for function return values, indicating no return | — |
fun foo() as void {}, defaults to void type |
null |
null, only one value: null; cannot be used in variable or function return type declarations | null | var n as int? = null |
In ago's type system, the base type is lang.any, from which lang.Primitive, lang.Object, and lang.Union are derived.
From lang.Primitive, lang.PrimitiveNumber is derived.
int, long, string, ... are parameterized Primitive and PrimitiveNumber types. For example:
final class void from Primitive::(0){}
final class null from Primitive::(2){}
final class string from Primitive::(3){}
final class int from PrimitiveNumber::(10){}
In ago, Union is also a parameterized class.
// type code is 0e
class Union from any{
metaclass{
public final classes as classref[];
fun new#2(class1 as classref, class2 as classref){
this.classes = [class1, class2];
}
}
}
It allows combining some types together.
But ago doesn't implement all Union features; currently it only has one Union type in use: Nullable.
class Nullable from Union{
metaclass{
fun new(baseClass as classref){
super(baseClass, null);
}
}
}
Nullable limits acceptable classes to only one — it's always BaseClass + null.
In actual usage, nullable uses the ? symbol like other languages.
Besides Nullable, any is also a commonly used Union type, which makes it both the base class of all classes and functionally equivalent to a union of Primitive, Object, and null.
| Type | Literal Syntax | Description | Example |
|---|---|---|---|
| Integer | Decimal, hexadecimal (0x...), octal (0777), binary (0b1010) |
May have suffix l/b; l for long type, b for byte type; case-insensitive |
var i = 42; var h = 0x2A; var o = 052; var b = 0b101010B; |
| Floating Point |
123.45, 1e-3, hexadecimal float (0x1p+2) |
May have suffix f/d/D; case-sensitive |
var f = 3.14f; var d = 1.0e10d; |
| Boolean |
true / false
|
— | var ok = true; |
| null | null |
— | var i as int? = null; |
| Character |
c'X', supports escape (\n, \\, \', \x41, \u0041) |
Must start with c
|
var ch = c'\n'; |
| String |
"..." or '...' (single quotes also work for strings) |
Supports escapes; double and single quotes don't conflict | var s1 = "hello\nworld"; var s2 = 'foo'; |
| Template String |
$" ... "$, ${expr} inside for interpolation |
Similar to JavaScript template literals, supports multiline and conditional insertion |
var a = 5; var t = $"a + b = {a+1} ${a} ${if(a>2) a*2}"$;
|
| Array/List Literal | [type|] |
[array_type| elem1, elem2, …] or shorthand [elem1, elem2, …], supports spread operator ...
|
Shorthand requires explicit type or inferrable context: `var arr = [int[] |
| Object/Map Literal | {type| key: expr, ... } |
Supports spread operator ...
|
var obj = { name: "John", age: 30 }; |
- Array literals can also be used for lists; object literals can also be used for Maps.
// Local variables (optional type, inferrable)
var x = 10; // int
var s = "hello"; // string
var flag as boolean = true;
// Fields (must explicitly state type, or use `as` inference before assignment)
name as string = "Tom";
age as int;
salary as double;
// Constructor parameters
fun new(name as string){
this.name = name; // age inferred as int
}
Key Points
varcan only be used for local variables; fields, constructor parameters, etc. do not supportvar.- For fields, you must provide a type or use
as Typedeclaration before assignment.- In ago, statement blocks
{}do not create scope; classes (including functions and traits) are the only scope for variables.
You can also declare belonging fields inside constructors:
class Person{
fun new(field name as string){ // Automatically creates `name` field and generates assignment `this.name = name`
}
}
| Modifier | Purpose | Example |
|---|---|---|
public / private / protected
|
Visibility control | public name as string; |
final |
Constant/immutable field | final PI as double = 3.1415; |
field |
Mark as instance field (can be used directly in constructors) | field counter as int; |
this (in development) |
Used for the first parameter of a function, adding extension methods to a class | fun concat(this s as String, another as string) |
// The compiler determines variable type based on the static type of the right-hand expression
var a = 5; // int
var b = "world"; // string
var c = true; // boolean
var arr = [1, 2, 3] // int[], inferred from first element
var d = new ArrayList<int>(); // List<int> (inferred via constructor)
Rules
vardeclarations must have an initialization expression.- Once the type is determined, assigning a different type later causes a compile error.
- Purpose: Group sub-expressions to enforce evaluation order or change precedence.
-
Syntax:
( expression ) -
Example
var x = (a + b) * c; // a+b completes before multiplication
- Purpose: Extract the inner type value from a nullable expression; throws NullPointerException if null.
-
Syntax:
expression! -
Example
var in as int? = 30 var i as int = in! + 20
- Purpose: Execute a function on an object or class member.
-
Syntax:
namePath(arguments)orawait|fork|spawn namePath(arguments) viaForkContext?(async calls). -
Example
var r = obj.compute(1, 2); fork worker(); // async call var r = await service.add(2,3); // async call, suspends current RunSpace
- Purpose: Get elements from arrays, collections, or maps.
-
Syntax:
expression '[' expression ']' -
Example
var val = arr[3]; var item = map['key'];
- Purpose: Access fields, methods, or properties; supports chaining.
-
Syntax:
expression '.' | '?.' (methodCall | namePath) -
Example
obj.field; obj.method(arg); obj?.maybeField; // nullable access
-
Purpose: Run a
CallFramefrom another RunSpace and return the result. -
Syntax:
AWAIT expression viaForkContext? - Example
var f = new add(1,2);
var i = await f; // i = 3
var j = await add(2,3)
var k = await add(4,5) via WorkflowRunSpace.new() // run within WorkflowRunSpace
-
Purpose: Apply
++or--to variables, properties, or elements; supports prefix and postfix. -
Syntax:
expression ('++' | '--')or('++' | '--') expression - Example
i++; // postfix increment
++j; // prefix increment
- Purpose: Apply arithmetic, logical, and bitwise operations to values.
-
Syntax:
('+' | '-' | '++' | '--' | bnot | not) expression-
Example
-x; // negate not flag; // logical NOT bnot x; // bitwise NOT
-
Example
-
Purpose: Force a value to a specified type; supports
asand pipe symbol, both identical — choose by preference. -
Syntax:
expression ('as' | '|') variableType -
Example
val as int; // cast to integer val | double; // cast to double
- Purpose: Create a new object by invoking a constructor.
-
Syntax:
new declarationType classCreatorRest -
Example
Java-style anonymous inner classes are not currently supported.
var p = new Person('Alice', 30); var contact = new p.Contact(); // assuming Contact is a subclass of Person
-
Purpose: Same as instantiation expression, but places
.newafter the type; supports explicitly specifying which constructor when multiple exist. Suitable for chaining. -
Syntax:
declarationType '.' NEW POST_IDENTIFIER? classCreatorRest -
Example
var node = TreeNode.new().child('root').value(5);
-
Purpose: Perform
* / %operations. -
Syntax:
expression ('*' | '/' | '%') expression -
Example
var r = a * b / c % d;
-
Purpose: Perform
+ -operations. -
Syntax:
expression ('+' | '-') expression -
Example
var sum = x + y - z;
- Purpose: Perform left/right shift operations.
-
Syntax:
expression ('<<' | '>>>' | '>>') expression -
Example
var shifted = value << 2;
- Purpose: Perform magnitude comparisons.
-
Syntax:
expression ('<=' | '>=' | '>' | '<') expression -
Example
if (a < b) { ... } - Note: Supports nullable narrow typing.
- Purpose: Check whether an object is an instance of a type; can capture the value into a variable when true.
-
Syntax:
expression instanceof variableType identifier? -
Example
if (obj instanceof String) { ... } if (animal instanceof Dog dog) { dog.spark() }
-
Purpose: Perform
== != === !==comparisons. -
Syntax:
expression ('==' | '!=' | '===' | '!===') expression -
Example
if (a == b) { ... }===and!==are not yet implemented; reserved. - Note: Supports nullable narrow typing.
- Purpose: Perform bitwise AND.
-
Syntax:
expression band expression -
Example
var mask = a band 0xFFb;
- Purpose: Perform bitwise XOR.
-
Syntax:
expression bxor expression -
Example
var toggle = a bxor b;
- Purpose: Perform bitwise OR.
-
Syntax:
expression bor expression -
Example
var flags = a bor b;
- Purpose: Perform short-circuit logical AND.
-
Syntax:
expression and expression -
Example
When one of the two expressions is boolean, the result is boolean. Otherwise, the result type follows implicit conversion rules. For object types, the left-hand type determines the result type.
if (cond1 and cond2) { ... } fun f() as Animal(){ ... } fun g() as Dog(){ ... } var c = f() and g() // results in Animal type - Note: Supports nullable narrow typing.
- Purpose: Perform short-circuit logical OR.
-
Syntax:
expression or expression -
Example
When one of the two expressions is boolean, the result is boolean. Otherwise, the result type follows implicit conversion rules. For object types, the left-hand type determines the result type.
if (cond1 or cond2) { ... } fun f() as Animal(){ ... } fun g() as Dog(){ ... } var c = f() or g() // results in Animal type - Note: Supports nullable narrow typing.
-
Purpose: Execute a postfix expression inside a
withblock; similar to a with statement — access external object members directly via dot notation within thewithblock. -
Syntax:
expression postWith -
Example
The expression's type matches the attached object expression's type.
var person = new Person() with {.name = 'Tom'; .age = 20} - Note: Supports nullable; if null, does not enter the block.
-
Purpose: Ternary operation similar to
a ? b : c. -
Syntax:
valueIfTrue if condition else valueIfFalse -
Example
The result type depends on the
var status = true if(score >= 60) else false;valueIfTruepart's type;valueIfFalseis implicitly converted to the result type.conditioncan be any type — in ago, any type can be implicitly converted to boolean. - Note: Supports nullable narrow typing.
- Purpose: Perform normal or compound assignment.
-
Syntax:
<assoc=right> expression '=' | o= | '+=' | '-=' | '*=' | '/=' | '%=' | and= | or= | band= | bor= | bxor= | '>>=' | '>>>=' | '<<=' expression -
Example
x = y; // normal assignment sum += val; // compound addition b and= false; // compound logical operation accountA o= accountB; // shallow copy assignment
o= supports dynamic assignment between objects and Maps. For example:
var map = {name: "Tom"|Object, gender: 'M', workNo : '1234', age : 44};
var staff = new Staff();
staff o= map;
For arithmetic operations, bitwise operations, logical operations between numeric types, and assignment operations, automatic type conversion is performed.
| Source Type | Implicitly convertible to |
|---|---|
byte |
short, int, long, float, double, decimal
|
short |
int, long, float, double, decimal
|
char |
int, long, float, double, decimal
|
int |
long, float, double, decimal
|
long |
double, decimal
|
float |
double, decimal
|
| Any type |
string for concat operations |
| Any type |
boolean for logical operations |
When float and int/long are combined, the result is converted to double.
Boxed types are unboxed before computation; enum types are also reduced to numeric types before computation.
In concat operations, any type combined with string results in string type; assignment to a string expression works similarly.
In logical operations, any type combined with boolean yields boolean; assignment to a boolean expression works similarly.
For logical operations between non-numeric and non-boolean types, the result follows the left-hand type; if the right-hand value cannot be implicitly converted to the left-hand type, a compile error occurs.
For arrays or lists created via array literals, varargs (arrays), etc., in assignment contexts, the receiver's type at assignment time determines the type. If not in an assignment context or the receiver type is undetermined, the first element's type is used. Subsequent elements are automatically converted to preceding types; if conversion fails, an error occurs.
ago statements end with ; or a newline sufficient to distinguish statement lines.
| Syntax | Description |
|---|---|
if(condition) statement (else statement)? |
condition can be any type expression; ago automatically converts non-boolean types to boolean (numeric zero or empty string is false, others are true). else can be omitted for single-branch if. |
Example
fun main(){
var a = 5
if(a > 0)
Trace.print("a is positive")
else
Trace.print("a is non‑positive")
if(a < 10)
Trace.print("a less than ten")
if(var i = a % 2)
Trace.print("a is an odd number, got " + i)
}
if statements support narrow typing for nullable values:
var i as int? = 20
if(i){
var j = i + 20; // i is unwrapped here
Trace.print("i yes, " + i);
}
ago currently only supports the switch control structure, not Java's switch expression:
| Syntax | Description |
|---|---|
switch(parExpression) '{' switchBlockStatementGroup* '}' |
Traditional switch: case and default blocks must use colon :; each case must be a constant expression, variable pattern, or enum value.break can be omitted; if not written, fall-through occurs. |
fun main(){
var day = 3 // 1=Mon, 2=Tue, ...
switch(day){
case 1: Trace.print("Monday"); break;
case 2: Trace.print("Tuesday"); break;
case 3: Trace.print("Wednesday");
// no break, falls through to next case
default: Trace.print("Other day")
}
}
Note:
switchcases can be enum values or any comparable-equal type (int, string, classref, etc.). If a non-primitive type is used, it degrades to multi-branch if - else if - else statements.
switch supports nullable narrow typing.
ago provides traditional for and enhanced for forms.
| Syntax | Description |
|---|---|
for '(' forInit? ';' expression? ';' forUpdate? ')' statement |
forInit can be a local variable declaration (e.g., var i = 0) or an expression list; expression is the loop condition; forUpdate is the update statement.Same as Java, supports type inference on var. |
for '(' variableModifiers? identifier (as declarationType)? in expression ')' statement |
Enhanced for (like foreach): iterates over any object implementing Iterable<T>, Iterator<T>, and supports arrays and generator functions.Syntax similar to Java's for (T t : iterable), supports type inference or explicit declaration. |
fun main(){
// Sum 1..10
var sum = 0
for(var i = 1; i <= 10; i++){
sum += i
}
Trace.print("sum=" + sum)
}
fun main(){
var list = new ArrayList<int>()
list.add(1); list.add(2); list.add(3)
// Iterate List[int]
for(var n in list){
Trace.print(n)
}
}
- Enhanced for supports nullable narrow typing on the iterated object.
- For arrays, enhanced for uses index-based iteration.
- Enhanced for supports iterating over generator functions:
generator fun foo(n as int) as int{
for(var i=0; i<n; i++){
yield i;
}
Trace.print('done');
}
fun main(){
for(var i in foo(3)){
Trace.print(i);
}
for(var i in fork foo(4)){
Trace.print(i);
}
}
| Syntax | Description |
|---|---|
while parExpression statement |
while: evaluate condition first, then execute loop body. Condition expression supports any boolean-convertible type. |
do statement while parExpression |
do‑while: execute loop body once first, then check condition; executes at least once. |
fun main(){
var i = 0
while(i < 5){
Trace.print(i)
i++
}
// do-while characteristic — executes at least once
var j = 10
do{
Trace.print(j)
j--
}while(j > 0)
}
Like if, condition expressions support any type.
Supports nullable narrow typing on conditions.
| Syntax | Description |
|---|---|
break identifier?continue identifier?
|
identifier is a label specifying the jump target. If omitted, defaults to the nearest loop or switch.break terminates the entire loop (or switch); continue skips the current iteration and proceeds to the next. |
label: statement // label name + colon
Labels can only be placed before statements that can be referenced by break/continue, limited to for, while, do-while statements.
fun main(){
for(var i = 0; i < 5; i++){
if(i == 2) break // exit loop
Trace.print(i)
}
}
fun main(){
outer: for(var i = 0; i < 3; i++){
inner: for(var j = 0; j < 5; j++){
if(i == 1 && j == 2){
Trace.print("break out of outer")
break outer // jump to after outer loop
}
Trace.print(i + "," + j)
}
}
}ago reserves goto as a keyword at the lexical level for future extension. However, no goto statement is implemented in the current grammar. Using goto will cause a compile error.
| Syntax | Description |
|---|---|
RETURN expression? eos |
expression can be omitted; if omitted, only usable inside functions returning void. If the function declares a return type, the expression must be implicitly convertible to that type (numeric → int, double, etc.). |
fun add(a as int, b as int) as int{
return a + b // directly return result
}
fun main(){
var r = add(3,4)
Trace.print(r) // 7
}
| Syntax | Description |
|---|---|
THROW expression eos |
Throw an exception object. The expression must be an instance implementing Throwable (or its subclass); if using new, be sure to pass constructor arguments in parentheses. |
class MyException from Exception{
fun new(msg as string){
super#message_cause(msg, null)
}
}
fun risky(){
var x = 0
if(x == 0)
throw new MyException("division by zero")
}
fun main(){
try{
risky()
}catch(e as MyException){
Trace.print("caught: " + e.message)
}
}
Tip:
throwcan be inside atry/catchblock, or throw an uncaught exception directly — the program will terminate or be handled by the runtime.
| Syntax | Description |
|---|---|
TRY block (CATCH '(' variableModifiers? identifier AS catchType ')' block)+ FINALLY? block? |
block is code wrapped in {}; catch can catch single or multiple types (comma-separated).Variable modifiers can be FINAL, FIELD, CHAN, etc.The finally block is optional and executes regardless of whether an exception was thrown. |
fun main(){
try{
// might throw exception
var r = 10 / 0
} catch(e as Exception){
Trace.print("caught: " + e.message)
} finally{
Trace.print("finally always runs")
}
}
Key Points
- Multiple
catchblocks execute in order; the first matching type handles it.- Throwing exceptions inside
finallyis discouraged, as it may mask original errors.
| Syntax | Description |
|---|---|
WITH parExpression statement |
When executing statement, bind the result of parExpression as the current "scope". Access members directly via . within the with block. |
class Person{
fun new(field name as string, field age as int){}
}
fun main(){
with (new Person('Unknown', 0)){
Trace.print(.name);
Trace.print(.age);
.name = "Tom";
.age ++;
Trace.print(.name)
Trace.print(.age)
}
}
| Syntax | Description |
|---|---|
VIA parExpression statement |
Similar to WITH, but requires the result of parExpression to implement the ViaObject interface; at runtime, enter() is called before entering the block and exit() after leaving. |
class Logger with ViaObject{
fun new(level as string){}
override enter(f as Function<_>){ Trace.print("logger start") }
override exit(f as Function<_>){ Trace.print("logger end") }
}
fun main(){
via (var log = new Logger('INFO')){ // auto enter/exit
Trace.print("do something")
}
}
Implementation Details
- The
ViaObjectinterface definesenter(Function<_>)andexit(Function<_>).- Semantically equivalent to:
var log = new Logger('INFO')
log.enter(fun.this)
try{
// body
}finally{
log.exit(fun.this)
}
In ago, a function itself is a class. This means every
fundeclaration generates a corresponding Function<R> subclass, and each actual call produces a CallFrame instance handed to the current RunSpace for execution. This section covers complete usage of functions and methods from syntax, parameters, return values, overloading, async, and more, with several typical examples.
[<visibility modifier> ...] fun <function name> [#<suffix>] [<generic params>] (<formal params>) [as <return type>] [with <interface/trait>...] [throws <exception list>]
{ // function body
...
}
| Element | Description |
|---|---|
<visibility modifier> |
public, private, protected (default public) |
fun |
Keyword to declare a function or method. |
<function name> |
Identifier; if multiple implementations exist in the same scope, distinguish by suffix. |
#<suffix> |
Separator for shared name and fully qualified name, used for call-time overloading (see 2.4.4). |
<generic params> |
E.g., T as [A to B]; functions also support type parameters. |
<formal params> |
Comma-separated; each can be written as name as Type or just as Type (using default value). |
as <return type> |
Function return type; if omitted, defaults to void. |
with <interface/trait>... |
Let the function implement one or more interfaces/traits for polymorphism. |
throws <exception list> |
Exception types thrown (optional). |
{ ... } |
Function body; supports all statements and expressions. |
Note: If you only want to declare an abstract function (e.g., in an interface or trait), omit the body and write
;or a newline directly.
name as Type // required
as Type // unnamed parameter, passed by position; if default value needed, use `= expr` afterward (parameter defaults still in development)
-
Default Values
The parameter can be omitted on call or explicitly passed a different value.
fun greet(name as string = "world") as string{ return "hello, " + name }
fun sum(values as int...) as int{
var total as int = 0
for(var v in values){
total += v
}
return total
}
On call, any number of int values can be passed, or a single int[].
-
No return
fun log(msg as string){ ... } // defaults to void return -
With return
fun add(a as int, b as int) as int{ return a + b }
-
Varargs must be at the end of the parameter list.
-
After declaring with
values as int..., callers can:- Pass individually:
sum(1, 2, 3) - Pass an array:
sum(values)(auto-unpacked to varargs)
- Pass individually:
-
Default values can use expressions as parameter defaults.
-
After declaring with
value as int = expr, callers can:- Pass the argument:
sum(1) - Use default value:
sum(default) - Default values are actually handled as a sub-function named
defaultValue_paramName; this name is fixed, and developers can call it themselves when needed, e.g.,nullableArg = nullableArg or defaultValue_nullableArg().
- Pass the argument:
Since functions are classes, you cannot define multiple implementations with the same name but different signatures as in traditional languages. ago uses full names and shared names to achieve call-time overloading:
fun f#1(a as int){
Trace.print("f#1: " + a)
}
fun f#2(a as int, b as int){
Trace.print("f#2: " + a + "," + b)
}
-
Auto-match on call
f(10) // compiler resolves to f#1 f(20, 30) // compiler resolves to f#2 -
Explicitly specify full name (for disambiguation or debugging)
f#2(5, 6)
The part after suffix '#' can use any characters valid in identifiers, even keywords.
The function's full name is its true name. For functions without a manually written #, the compiler appends a # symbol to the end of the function name. Therefore, any complete function name must contain a # symbol.
-
Instantiation
var t = new f(1, 2) // Create CallFrame instance, stored in variable t -
Execution
t() -
Direct call (syntactic sugar)
f(1, 2) // Equivalent to f(1,2).new()()
In ago, function calls are syntactic sugar for two operations: construction and execution.
ago implements cooperative concurrency through three keywords: await, fork, spawn:
| Keyword | Meaning | Call Style |
|---|---|---|
await statement |
Pause current RunSpace, wait for notify to resume. | Write standalone await statement inside function body. |
await |
Pause current RunSpace, wait for child frame completion then resume. |
await f(args); or await expr (expr is a CallFrame object). |
fork |
Create new RunSpace and immediately execute given function; parent frame auto-waits for completion. |
var child = fork f(args), returns child frame instance, can continue via child.notify(). |
spawn |
For top-level RunSpace, same effect as fork. For child RunSpaces, spawn requests the parent to create a sibling frame; this frame need not wait for its completion. |
spawn f(), returns child or sibling frame instance. |
fork and spawn also support callFrame. Note that await has different meanings as a function call vs. as a standalone statement.
// Send message to CallFrame via MQ
fun send(channel as Function<_>, message as Object) native "ago.native.sendMessage";
// CallFrame receives message
fun receive<T>(channel as Function<_>) as T native "ago.native.receiveMessage";
class Numbers{
fun new(field a as int, field b as int){}
}
fun main(){
var f = new work(fun.this); // Create CallFrame
spawn f() // async start CallFrame
sleep(100) // let receive install listener first
send(f, new Numbers(2, 3)) // send message to work
await; // suspend, wait for work notification
}
fun work(caller as Function<_>){
var n = receive<Numbers>(fun.this) // receive message
Trace.print(n.a + n.b) // compute
caller.notify() // notify caller
}
When a CallFrame reaches the
awaitinstruction, it sets its state to WAITING_RESULT, and resumes via notify when an event arrives.
-
Nested Definition
fun outer(x as int){ fun inner(y as int) as int{ return x + y // captures external variable x } return inner(5) } -
Closure Implementation
-
inner's CallFrame capturesouter's scope upon creation. - When
inneris called, it can access and modifyx.
-
Like functions, classes also have similar closure properties; the two can be mixed.
ago currently does not support lambda expressions nor writing functions/classes as values; instead, classref and its boxing types like ScopedClassInterval are used to pass types.
You can declare the first parameter name of a top-level function as this, adding an extension method for that parameter's class.
Extension methods work on both objects and primitive types. For example:
fun neg(this as int) as int{
return -this;
}
fun main(){
var i = 1;
i = i.neg();
Trace.print(i); // -1
}
Clearly, extension methods are merely syntactic sugar for function calls; the class doesn't truly gain new members.
Generator functions can output multiple values via yield, and enhanced for loops can retrieve their return values sequentially.
Usage:
generator fun foo(n as int) as int{
for(var i=0; i<n; i++){
yield i;
}
Trace.print('done');
}
fun main(){
for(var i in foo(3)){
Trace.print(i);
}
for(var i in fork foo(4)){ // async execution
Trace.print(i);
}
var it = new GeneratorIterator<>(testContinue(8)); // wrap as Iterator
if(it.hasNext()) Trace.print(it.next());
if(it.hasNext()) Trace.print(it.next());
}
generator fun testContinue(n as int) as int{
for(var i=0; i<n; i++){
if(i % 2 == 0) continue;
yield i;
}
Trace.print('done');
}
ago functions derive from Function<R> by default, but generator functions derive from Generator<R>, a subclass of Function<R>.
class Generator<+R> from Function<R>{
private done as boolean {public get;}
}
ago's generator implementation is based on the idea that function instances are CallFrame objects. During for-loop iteration, the loop holds the generator's corresponding CallFrame object and repeatedly invokes it. Upon encountering yield, the result is stored in RunSpace's result cache and control returns to the calling function to continue the loop body. After the loop body completes, since done is still false, that CallFrame executes again until a return statement in the generator sets done to true.
When using Entity objects and EntityRunSpace, query functions can be used.
For example:
query userByName(name as string?, minAge as int?, maxAge as int?, sort as Sort[]? = [Sort[] | new Sort('u.name', 'asc')]) {
sql{.
SELECT u.id, u.name, u.age FROM User u WHERE name = :name AND age >= :minAge AND age <= :maxAge
ORDER BY :sort ASC
.}
}
class User from Entity<User, long> {
metaclass{
class Name from VarChar::(100)
class Phone from VarChar::(200)
class Age from BigInt
}
public name as Name;
public phone as Phone;
public age as Age
}
The SQL statement in the {. .} section is processed using schema lineage technology:
- Type names are replaced with actual database table names.
- Field names are replaced with actual database column names.
- Named parameters like
:nameare filled with same-named function arguments. For nullable parameters, when the parameter value is null, that clause segment is pruned. E.g.,WHERE name=:name— when the name parameter is null, this clause is eliminated. If it'sWHERE name=:name AND age > :minAge, it becomesWHERE true AND .... - Similarly, ORDER BY sections are also replaced and pruned.
ago automatically generates a class of type functionName.Result for query results as the function's return type. This class tracks possible field mappings, preserving field types as much as possible. In this example, userByName#.Result will include:
name as User.Name
age as BigInt
You can use this Result type in other contexts.
For DML statements like UPDATE and INSERT, the function returns an int type.
fun add(a as int, b as int) as int{
return a + b
}
fun main(){
var result = add(3, 4)
Trace.print(result) // 7
}
fun sum(values as int...) as int{
var total as int = 0
for(var v in values){
total += v
}
return total
}
fun main(){
Trace.print(sum(1,2,3)) // 6
Trace.print(sum([int[]|4,5,6])) // 15
}
fun f#1(a as int){
Trace.print("f#1: " + a)
}
fun f#2(a as int, b as int){
Trace.print("f#2: " + a + "," + b)
}
fun main(){
f(10) // f#1
f(20,30) // f#2
}
fun counter(start as int) as Function0<int>{
var count as int = start
fun inc() as int{
count += 1
return count
}
return new inc()
}
fun main(){
var inc = counter(10)
Trace.print(inc()) // 11
Trace.print(inc()) // 12
}
In ago, everything is an object; classes themselves are objects with structures like slots and scope at runtime. From a language perspective, classes are instances of metaclasses. First-level metaclasses are instances of second-level metaclasses; second-level metaclasses are instances of the top-level metaclass. If there's no second-level metaclass, the first-level metaclass is directly an instance of the top-level metaclass, and the top-level metaclass is its own instance. Through
from,with, inheritance, Trait mixing, and interface implementation are supported, along with abstract classes, final classes, and static members.
classModifier* CLASS className=identifier genericTypeParameters? extendsPhrase? implementsPhrase? permitsType? classBody
-
classModifier:-
PUBLIC | PROTECTED | PRIVATE | ABSTRACT | FINAL | NATIVE.
-
-
Generic Parameters:
class G<+T as [Animal to _]>{ … } -
Inheritance and Implementation (order cannot be swapped):
-
fromfor single inheritance; if no parent class, implicitly inherits fromObject. -
withfor implementing interfaces or Traits; the latter can have default implementations.
-
-
Access Modifiers: Access control can be applied to entire classes; member-level also supports
public/protected/private/final. -
Metaclass (static part):
class Foo{ metaclass{ … } }Fields and methods inside
metaclassare accessible externally viaClassName.memberName, and internally directly bymemberName.- Subclass metaclasses inherit from parent class metaclasses.
| Member Type | Keyword / Syntax | Example |
|---|---|---|
| Field | fieldModifier* fieldVariableDeclarators |
private age as int = 22; |
| Method/Function | methodStarter methodName genericTypeParameters? formalParameters typeOfFunction? implementsPhrase? throwsPhrase? methodBody? |
public fun getName() as string{ … } |
| Constructor |
fun new#0(...), fun new#init(field ...)
|
fun new#0(){…} or fun new#init(name as string){this.name = name;}
|
| Inner class / trait / interface | Written directly inside class body |
class Inner{ … }, trait TraitX{ … }, interface I{ … }
|
Field and Method Access Modifiers
public: accessible by any code.protected: only accessible by subclasses and classes in the same package.private: only accessible within this class.final: can only be initialized at declaration, cannot be reassigned.native: specifies creating objects with native payload; see below.
-
Default Constructor
If nonewmethod is explicitly written, the compiler auto-generates a parameterless constructor that calls the parent's default constructor (if the parent has no parameterless constructor, an error occurs). -
Overloaded Constructors
Constructors are also functions; call-time overloading can be achieved via full name/shared name.fun new#0(){ … } // parameterless fun new#1(name as string){ … } fun new#init(field name as string, field age as int){ … } -
Constructor Invocation
- Subclass constructors implicitly execute
super()(parent's parameterless constructor) by default. To explicitly call:fun new(name as string){ super#0() // call parent's #0 constructor this.name = name }
- Subclass constructors implicitly execute
-
Instantiation Syntax
var p = new Person(); // calls parameterless constructor var c = new Car("Toyota", 2022, "Blue", 4); // auto-matches corresponding constructor var p = Person.new#1('Tom') // use chained creator to specify concrete constructor
-
Single Inheritance
class Vehicle{ … } class Car from Vehicle{ … } // extends Vehicleago classes follow single-root inheritance.
-
Implementing Interfaces / Trait Mixing
interface Drivable{ fun drive(); } trait Loggable{ fun log(msg as string){ Trace.print(msg); } } class Car from Vehicle with Drivable, Loggable{ override drive(){ this.log('vroom vroom vroom!') } } -
Method Override
- Must match parent signature exactly (full name, parameter types, return type).
- Must use the
overrideidentifier.
class A{ public fun foo() as int{ return 1; } } class B from A{ override foo() as int{ return 2; } }After using override, the
funkeyword is optional. -
Abstract Classes and Methods
- Abstract classes cannot be directly instantiated; all abstract members must be implemented in subclasses.
abstract class Shape{ abstract fun area() as double; } class Circle(radius as double) from Shape{ override area() as double{ return Math.PI * radius * radius; } } -
Final Classes
- Classes modified with
finalcannot be overridden.
- Classes modified with
ago does not support static members; it only has the concept of class members.
Metaclass members are not equivalent to Java static members because first-level metaclasses in ago can have parameterized constructors, producing parameterized classes from different constructor values. Parameterized classes are objects of first-level metaclasses, and their instances are independent. In such cases, class members don't correspond to Java's static members. To achieve static member effects, declare a second-level metaclass.
ago allows at most two levels of metaclasses; second-level metaclasses cannot have parameterized constructors.
The following example demonstrates using class members as static members:
class StaticExample{
metaclass{
public classVariable as int = 2024;
public fun classMethod(){
Trace.print("this is a class method");
}
}
// instance method
public fun instanceMethod(){ … }
}
// Usage
StaticExample.classVariable = 10; // modify class field
StaticExample.classMethod(); // call class method
var e = new StaticExample();
e.instanceMethod(); // call instance method
ago provides this, fun.this, class.this, trait.this, and other pronouns to locate different entities within scope.
Inside a class, this refers to the nearest enclosing class; inside a trait, this refers to the nearest enclosing trait.
In functions, fun.this accesses function members; more commonly it's used to access the CallFrame object itself.
Additionally, ClassName.this (including function names and trait names) can locate corresponding scopes within scope.
class A{
class B{
private f as int;
public fun new(){
f = 2;
}
fun m(p as int) as int{
var f as int = 1;
class G{
metaclass{
f as int
}
}
Trace.print(this.f) // 2
Trace.print(f) // 1
my.test.A.B.this.f = B.this.f + this.f // all ref to B.this.f
Trace.print(this.f) // 4
fun.this.f = m.this.f + class.this.f + 2024
Trace.print(f) // 1 + 4 + 2024 = 2029
Trace.print(fun.this.f) // 2029
G.f = p + 1;
Trace.print(G.f) // 4, 5, 6
return G.f + f; // 2033, 2034, 2035
}
}
public fun b() as B{
return new B();
}
}
class B{
private f as int = 3
metaclass{
public fun main(args as string){
//var t = new A.B() // error: B inside A scope
var a = new A()
var b as A.B = a.b()
Trace.print(b.m(3)) // 2033
Trace.print(a.b().m(4)); // 2034
}
}
}
Traits and Interfaces are two extremely important abstract types in ago.
- Interface mainly describes a "what it can do" contract — only declares method signatures and property accessors, cannot hold field implementations.
- Trait is a reusable code block (similar to Java 8 default methods), which can both declare methods and provide default implementations and state (fields). They can be composed into classes and can inherit from and mix with each other.
| Item | Trait | Interface |
|---|---|---|
| Definition | A reusable code block that both declares methods and implements default logic; can carry fields and constructors. | Only declares abstract members (method signatures/property accessors), provides no implementation. |
| Inheritance |
trait T from U: T inherits from parent Trait U. No multiple inheritance. |
interface I from J, K: I extends other interfaces; supports multiple extension. |
| Implementation |
class C with T1, T2: class C mixes in Traits T1 and T2; trait methods are copied into C (resolve conflicts manually if they occur). |
class C implements I, J: C must implement all interface members. |
| Fields | Traits can have their own fields; only for internal use within the trait and attached class, not externally accessible. | No fields allowed; only property accessors ({get;}, {set;}). |
| Constructor | Traits can define one parameterless constructor as an aid during instantiation. | No constructors; classes implementing them must provide their own construction logic. |
| Polymorphism & Covariance/Contravariance | Via <+T as [A to B]> bounds, generic type parameters can be declared in traits supporting covariance or contravariance. |
Similarly uses generic bounds but only for method signatures; trait fields cannot directly use generic parameters (unless via classref). |
| Default Implementation | Any method can be implemented inside a trait; subclasses inherit or override directly. | No default implementation; all members are abstract. |
Why introduce Traits?
In traditional OOP languages, multiple inheritance often leads to "diamond" conflicts and code duplication. Traits encapsulate reusable behavior and state in lightweight modules, preserving single-inheritance advantages while enabling multiple composition.
interfaceDeclaration
: interfaceModifier* interface identifier genericTypeParameters? [from interface1,interface2,...]? [for permit_class]
classBody
;
traitDeclaration
: classModifier* trait identifier genericTypeParameters? [from Trait] [with interfaces/traits] [for permit_class]
classBody
;
Permit class refers to the types (including functions, interfaces, traits) that an interface or trait applies to. This is for restricting improper usage.
For example:
interface Flyable for Animal {
fun fly();
}
Constrains Flyable to animals only.
For interfaces/traits with a specified permit class, implicit conversion to the permit class is supported.
var f as Flyable = new Duck();
var a as Animal = f // f must be an Animal
In traits, class.this can access the permit class instance; even if the permit class is a function or interface, class.this can still be used.
In permit classes that have mixed in traits, trait.this accesses trait members.
// Simple Trait (no inheritance)
trait Logger {
logLevel as string = "INFO"
fun setLogLevel(level as string){
Trace.print("set log level " + level)
this.logLevel = level
}
fun log(message as string){
Trace.print(logLevel + ": " + message)
}
}
// Trait inheriting from another Trait (multiple inheritance with comma)
trait Configurable for Worker {
timeout as int = 2000
fun getTimeout() as int{
return this.timeout
}
fun setTimeout(v as int){
trait.this.timeout = v;
Worker.this.delay(timeout)
class.this.delay(timeout)
}
}
Notes:
- Traits do not support closure properties and cannot be declared inside classes or interfaces.
- A
traitcan only have one constructor, with no constructor parameters.- Due to function name uniqueness, ago doesn't support naming conflicts — if two traits have methods of the same name, only the first-appearing method is taken.
interface Producer<+T as [Animal to _]> { // covariant
fun produce() as T;
}
interface Consumer<-T as [Animal to _]> { // contravariant
fun consume(a as T);
}
Notes
Producer's type parameter is covariant (+), meaningProducer<Cat>can be assigned toProducer<Animal>.Consumer's type parameter is contravariant (-), meaningConsumer<Animal>can acceptConsumer<Cat>.- ago's template classes (including functions and interfaces) all support variance.
interface ICounter {
fun size#get() as int;
}
class SimpleCounter implements ICounter {
private cnt as int = 0;
override fun size#get() as int{
return cnt;
}
fun inc(){
cnt += 1;
}
}
Property Accessors
size#getandsetsyntax is similar tofun name#get/ set(name as type); in interfaces only declaration, classes must provide concrete logic.- For read-only properties, use
{get;}directly.
Traits can implement interfaces, extend other traits, and be implemented or mixed by other traits or classes. This combination provides powerful expressiveness for advanced design.
// RestService is an interface with path info, defining HTTP methods and paths
trait RestService<R> for Function0<R>{
metaclass{
httpMethod as HttpMethod = HttpMethod.Get;
path as string;
fun new#GET(path as string){
this.httpMethod = HttpMethod.Get;
this.path = path;
}
fun new#method(httpMethod as HttpMethod, path as string){
this.httpMethod = httpMethod;
this.path = path;
}
}
get session() as Map<string, Object> { ... }
}
// Mix in
class GreetService with RestService<string>::('/greeting') {
fun call() as string{
return "hello world" + session['username'];
}
}
Explanation
RestServiceitself is a Trait, usingfor Function0<R>to indicate it only applies to 0-parameter functions. The part afterforis the permit type.- In class
GreetService's declaration, mixed in viawith RestService<string>::('/greeting'), passing path parameters — this is a parameterized class.
trait Logging from Logger {
override fun log(message as string){
Trace.print("[LOG] " + message);
super.log(message); // call parent trait's implementation
}
}
class MyService with Logging, Configurable {
// ...
}
Notes
Logginginherits fromLogger, overrides thelogmethod, and internally callssuper.log().- By mixing into
MyService, finer-grained log control is obtained.
Besides traits, object fields matching interface types can be wrapped as interfaces without implementing each interface method individually. For example:
interface Logger {
fun log(message as string);
}
class LoggerImpl with Logger {
logLevel as string = "INFO"
override log(message as string) {
Trace.print(logLevel + ": " + message)
}
}
class UserService with (logger as Logger) {
logger as Logger = new LoggerImpl() // logger field auto-created if it doesn't exist
fun createUser(username as string) {
log("Creating user: " + username)
}
}
In fact, ago's traits are implemented this way.
Metaclasses were introduced earlier; below we elaborate with parameterized classes and boxing concepts.
The Boxer class is defined in lang.ago:
class Boxer<T as [Primitive]>{
fun new(field value as T){
}
}
For example, String derives from this class:
public class String from Boxer<string>{ ... }
When no boxing type is specified, ago defaults to using lang.Integer for int, lang.String for string, etc.
But if data has an explicit type, ago uses the given type for boxing. For example:
class Email from String{
}
var email as Email = 'somebody@example.com'
Default system boxing types don't invoke constructors; they only set the first field's value to the provided value. Unboxing extracts the first field's value without calling getValue.
class Foo{
/* ...normal members... */
metaclass{ // <--- keyword
fun new#0(){ // constructor (optional)
// ...
}
fun new(field size as int){ // parameterized constructor
this.size = size;
}
/* can also define fields, methods, etc. */
myStaticField as int = 42;
fun staticMethod(){
Trace.print("static method");
}
}
/* ...instance members... */
}
-
metaclass{}must be inside the class body and can only appear once. - Inside
metaclass{}, syntax is nearly identical to normal classes: fields, methods, constructors can be declared; cannot implement other interfaces. Subclass metaclasses always auto-inherit parent metaclasses. - Since metaclasses are anonymous, their names aren't directly usable externally; they're only accessed at compile time and runtime via the
Class.getClass()chain.
| Scenario | Need | Solution Provided by Metaclass |
|---|---|---|
| Parameterized Class | Want class VarChar from String to have a length parameter, generating subtypes of different lengths. |
Write fun new(field maxLength as int) in metaclass; compiler resolves VarChar::(200) as "invoke metaclass constructor, return instantiated new class". |
| Class Fields/Methods | Class-level caches, counters, utility functions. | Declare directly in metaclass; access via <ClassName>.<field/method> or at instance level via this.getClass(). |
| Annotation-style Programming | Attach extra metadata to classes or methods (e.g., REST paths, transaction attributes). | Use with combined with parameterized interfaces, define constructors in the interface's metaclass; unlike annotations, implementing classes can access these parameter values via [ClassName.]member. |
| Type-safe Generic Constraints | Specify T must be a certain class or its subclass via class G<+T as [Animal to _]>. |
Metaclasses implement corresponding constructors and validation logic; legality checked at compile time. |
ago supports scalarizing classes into a primitive type called classref. For example:
class Animal{}
fun main(){
var c as classref = Animal
}
To give classref more semantics, ago provides special boxing types for it. The core wrapper type is ClassRef, inheriting from Boxer<classref> and holding a real AgoClass instance internally:
// box type for classref
class ClassRef from Boxer<classref>{
protected final value as classref;
protected final boxedClass as Class; // an agoClass instance, set value when boxing classref
fun new(value as classref) {
this.value = value;
}
final fun getBoxedClass() as Class {
return boxedClass;
}
public override toString() as string native "org.siphonlab.ago.lang.Lang.ClassRef_toString"
}
When boxing into a ClassRef, the box_C_xxx instruction sets both the first field value and also boxedClass.
Direct use of ClassRef is limited; its subclasses are used more often.
class VarChar from String{
metaclass{
fun new(field maxLength as int){
this.maxLength = maxLength;
}
}
}
var name = new VarChar::(200)(); // creates parameterized class of length 200, instantiates empty string object
var name as VarChar::(200) = 'Tom' // boxes into parameterized class
-
newcan be parameterless (default constructor) or with one or more parameters. - Parameters can have field declarations, e.g.,
field maxLength as int; the compiler treats them as fields of the metaclass instance (i.e., the class). - On invocation, use ::(params) syntax:
Note:
VarChar::(200)returns a new class, not an object. Adding()afterward gets an instance of that class. Parameterized classes are generated at compile time, so parameters must be constant values. If the same parameterization appears multiple times in one file (e.g.,VarChar::(200)andVarChar::(200)), the compiler merges them without duplicate creation.
Metaclass constructors execute sequentially when AgoEngine starts. Entry functions like main# are called only after all constructors complete.
Fields declared in metaclasses become accessible; inside objects via maxLength, or externally via VarChar.maxLength.
- First level: The class itself.
- Second level: Metaclass.
You can nest another
metaclass{}inside a metaclass, but no deeper levels are allowed. Reasons:
- Two levels suffice to simulate static members;
- Deeper nesting easily causes circular dependencies and is hard to maintain.
class VarChar from String{
metaclass{ // metaclass begins
field maxLength as int;
fun new(field maxLength as int){
this.maxLength = maxLength;
}
fun validate() throws ValidationException {
if(this.length > maxLength) throw new ValidationException("exceeds max length");
}
} // metaclass ends
fun isValid() as boolean{
return this.length <= maxLength;
}
}
Usage:
var name as VarChar::(200); // create subclass with length 200
name = "Tom"; // auto-boxes into VarChar::(200)
if(name.isValid()){
Trace.print("valid");
} else {
Trace.print("invalid");
}
interface Restful{
metaclass{ // though interfaces can't hold data, as a type they can
field _path as string;
fun new(field _path as string){
this._path = _path;
}
/* implements Restful's path#get */
fun path#get() as string{
return _path;
}
}
fun path#get() as string;
}
class Article with Restful::('/article/${id}'){ // mix in interface at class level
fun get(id as long) as Article{
// ...
}
}
Creating instances:
var art = new Article();
startService(art);
class Outer{
metaclass{ // first-level metaclass
fun new(){
Trace.print("Outer meta");
}
/* second-level metaclass */
metaclass{
fun new(){
Trace.print("Inner meta");
}
}
}
}
ClassInterval is a subclass of ClassRef, specifically for describing upper and lower bounds of a type scalar, forming a "type interval". Its parameterized classes can record bounds:
class ClassInterval from ClassRef{
metaclass{
public final lBound as classref
public final uBound as classref
fun new(lBound as classref, uBound as classref){
this.lBound = lBound
this.uBound = uBound
}
}
fun new(value as classref){
this.value = value;
}
}
Since classes in ago are closure-based, scope information often needs to be carried. Hence ScopedClassInterval was introduced, which holds an additional scope field on top of ClassInterval to record the current instance's scope.
// mostly use this, the sugar code is `var T as [Animal to Cat]`
class ScopedClassInterval from ClassInterval{
metaclass{
fun new(lBound as classref, uBound as classref){
super(lBound, uBound)
}
}
public final scope as Object // assigned by opcode
}
For easier use of ScopedClassInterval, ago provides syntactic sugar as [L to U] and like L, the latter being equivalent to as [L to _].
ScopedClassInterval enables passing classes with scope information as parameters. For example:
fun test(){
var s = 'foo'
class Animal{
fun speak(){
return s;
}
}
class Cat from Animal{}
var c as [Animal to _] = Cat
}
Type interval checking for ClassInterval is actually done at compile time. The boxedClass value in ScopedClassInterval instances is a copy of the agoClass made for that scope.
ScopedClassInterval can implement function callbacks. For example:
fun add(a as int, b as int) as int{
return a + b
}
fun add2(a as int, b as int) as int{
return a + b + a + b
}
class C{
i as int
fun new(i as int){
this.i = i;
}
fun add(a as int, b as int) as int{
return this.i + a + b
}
fun add2(a as int, b as int) as int{
return i + a + b + a + b
}
}
fun test(op like add, a as int, b as int){
Trace.print(op(a, b))
}
fun main(){
test(add, 1, 2) // 3
test(add2, 1, 2) // 6
var c = new C(100);
test(c.add, 1, 2)
test(c.add2, 1, 2)
var f like add = c.add2 // `like add` is equivalent to `as [Function<int, int>]`; ago compiler auto-implements generic interfaces `Function<R>` and `FunctionN<R, T1, ..., TN>` for function classes. The `like` keyword automatically selects the FunctionN interface's ClassInterval for functions rather than the function itself
Trace.print(f(3, 4))
test(f, 10, 20)
}
As shown, with parameterized classes and boxing technology, type intervals and closure-based callbacks can already be expressed. Below we continue exploring its application in generics.
class G<+T as [Animal to _]>{ ... }
| Keyword | Meaning |
|---|---|
<...> |
Begin type parameter list |
+ / -
|
Covariant/Contravariant; default is invariant. |
T |
Type variable name, similar to Java's T, C#'s T. |
as [L to U] |
Upper/lower bound constraints; [L to U] means "type interval from L to U". If to U is omitted, defaults to to _ (Any). |
Notes
- Variance symbols combined with bounds ensure safe subtype substitution when used in generic methods/classes.
as [Animal to _]is equivalent to "T must be Animal or its subclass".- To restrict to only a specific type, write
as [Cat to Cat].
class G<+T as [Animal to _]>{
t as T; // member fields can directly use type variables
fun new(t as T){ this.t = t;}
}
Since functions are also classes, function generics and class generics are unified.
fun add<T>(a as T, b as T) as T{
return a + b; // for numeric types, corresponding operation instructions are generated
}
ago's generics support primitive types, generating different instructions for different primitive types during specialization.
With type boxing technology and parameterized class concepts, generic type parameters can also be understood as a special kind of ClassInterval. Their values don't carry scope information, but type parameters need to specify variance rules. ago's type parameter syntax is +-T as [LClass to UClass], where + - represent covariance and contravariance; default is invariant. ago uses the parameterized class GenericTypeParameter to record type parameter types.
// defined in lang.ago
// for G<T>, i.e. G<+T as [Animal to _], -T2 as [_ to Cat]>
class GenericTypeParameter from ClassInterval{
metaclass{
public final variance as byte
fun new(lBound as classref, uBound as classref, variance as byte){
super(lBound, uBound)
this.variance = variance
}
}
}
This class interval syntax not only stays consistent in form with ScopedClassInterval but also indeed generates parameterized classes like GenericTypeParameter::() during actual compilation.
Formally, generic classes can be understood as the template class's parameterization on type parameter values.
For example, for the following generic class:
class G<+T as [Animal to _]>{}
It has the following logical correspondence:
class G{
metaclass{
fun new(T as GenericTypeParameter::(Covariant, Animal, Any)){
// apply template and return instantiated class
}
}
}
Thus, concrete class G<Cat> can be considered the parameterized class of G::(T=Cat).
For dynamic languages, this understanding of type parameters suffices as an implementation blueprint. But ago, being a static language, its generics are essentially still a template technique. So while the parameterized class corresponding to generic type parameters, GenericTypeParameter::(), indeed exists, G::(Cat) does not — what exists is still G<T>.
Using scalarized classref and this set of special boxing types instead of metaclass types is because in ago, types usually carry scope information and have upper/lower bound constraints that metaclass types cannot carry. Additionally, metaclasses are prone to circular dependencies and hard to engineer. ago constrains metaclasses as anonymous classes limited to 2 levels, allowing developers to avoid dealing with them directly.
All functions in ago (including methods) are assigned a parent class Function<R> at compile time, defined in lang.ago. This is the base class for CallFrame objects.
Simultaneously, the compiler generates a FunctionN<Result, Arg1, Arg2, ... ArgN> interface based on parameter count and types. This interface can be used for type intervals and callbacks, as described earlier.
When functions have type parameters, the compiler:
- Generates corresponding
FunctionN<…>interfaces for each parameter count. - Binds generic type parameters to ClassInterval in implementations.
- Provides callers with
likeoras [Type]syntax for reference in code.
fun add<T>(a as T, b as T) as T{
return a + b;
}
On call:
var f as Function<int> = new add(3, 4); // base class
Trace.print(f()); // outputs 7
At runtime, for template classes, AgoClass.concreteType is of type GenericTypeParametersInfo, where the GenericParameterTypeInfo[] genericParameters array:
public class GenericTypeParametersInfo extends ConcreteTypeInfo {
private GenericParameterTypeInfo[] genericParameters;
}
public class GenericParameterTypeInfo extends TypeInfo {
private final String parameterName;
private final AgoClass sharedGenericTypeParameterClass;
...
}The sharedGenericTypeParameterClass above is the parameterized class of GenericTypeParameter described earlier.
For concrete generic classes, type values for type parameters are stored; type parameter types can be obtained via template class and parameter index.
Despite this, currently at runtime there are still insufficient tools from the ago language level to detect generic information.
Below is a UML diagram of core class structures in the ago runtime (Java implementation), followed by explanations for each class.
@startuml
' ---------- Class Definitions ----------
class Instance<C extends AgoClass> {
- C agoClass ' corresponding meta-object
- Instance parentScope
- Slots slots
}
class AgoClass <C=MetaClass> {
- String fullname
- String name
- SlotsCreator slotsCreator
+ AgoField[] fields
+ AgoSlotDef[] slotDefs
+ AgoClass cloneWithScope(Instance<?> parentScope)
}
class MetaClass extends AgoClass{ }
class AgoFunction <C=MetaClass> {
- int[] code // ago vm code
}
abstract class CallFrame<C extends AgoFunction> {
- CallFrame<?> caller
+ void run()
+ void resume()
+ void interrupt()
}
class AgoFrame<C=AgoFunction> {
- int pc // program counter
}
' ---------- Inheritance Relationships ----------
Instance <|-- AgoClass : extends
AgoClass <|-- AgoFunction : extends
Instance <|-- CallFrame : extends
CallFrame <|-- AgoFrame : extends
' ---------- Reference Relationships ----------
Instance "0..*" -- "1" Instance : parentScope
@endumlExplanation:
-
Instanceis the base class for all objects (including CallFrames, normal classes, functions, and metaclasses). It holds a reference to its own class (agoClass), slot collectionslots, and scope, enabling any object to have closures. - Normal class
AgoClassalso derives from Instance; normal classes are instances of metaclasses (MetaClass). - Function classes derive from AgoClass with their own
codeattribute — an array holding compiled ago VM instruction sequences. - CallFrame describes a runtime frame for a function call. It holds a reference to the parent frame (
caller) and provides methods for execution, suspension, and interruption. - AgoFrame is a subclass of CallFrame that additionally maintains program counter
pcfor interpreting VM instructions one by one.
All objects (including Instance, CallFrame, and AgoClass) read/write state via implementing the Slots interface. Below is an example interface definition:
public interface Slots {
default int getInt(int slot) { /* ... */ }
default long getLong(int slot) { /* ... */ }
default void setInt(int slot, int value) { /* ... */ }
default void setLong(int slot, long value) { /* ... */ }
default Instance<?> getObject(int slot) { /* ... */ }
default void setObject(int slot, Instance<?> value) { /* ... */ }
}The runtime dynamically generates an anonymous Slots implementation for each ago class. For example, a class with only two integer slots gets code like:
class SlotsImpl implements Slots {
private int a_0;
private int b_1;
@Override
public int getInt(int slot) {
switch(slot){
case 0: return a_0;
case 1: return b_1;
default: throw new IllegalArgumentException("Unsupported slot access: " + slot);
}
}
@Override
public void setInt(int slot, int value) {
switch(slot){
case 0: a_0 = value; break;
case 1: b_1 = value; break;
default: throw new IllegalArgumentException("Unsupported slot access: " + slot);
}
}
}CallFrames and normal objects share the same Slots structure. In ago's runtime, all frames are allocated on the heap rather than relying on stack space, simplifying memory layout. Based on this unified slot model, a register-style VM instruction set was designed; compiled ago code produces corresponding instruction sequences.
Slots is a Java interface; Java developers can provide different Slots implementations for different underlying storage. The author leveraged this to write a Slots implementation class for PostgreSQL JSON fields, enabling object state to be persisted directly to the database. I also implemented loading objects (including CallFrames) from the database only when execution is needed, and writing back and releasing memory upon completion or suspension. This implementation can replace traditional workflow engines.
Additionally, I tried mapping VM instructions directly to SQL statements: for example, a mov instruction corresponds to an UPDATE operation, so Slots can be updated without loading into memory. I also developed a lightweight ORM-mode slots where each class maps to a table and Slots map to fields; ORM mode supports both cached and cacheless forms — details omitted here.
It's conceivable that when Slots are stored in databases, we could even inject transaction semantics into CallFrames, making them a persistence language.
ago designed a container called RunSpace to run CallFrames, performing the work previously done by Threads. RunSpace's responsibility is to track the current active frame and drive its execution while providing shared result storage for all frames within the same space. Unlike traditional threads, RunSpace doesn't need to maintain a full call stack — CallFrame objects maintain the call chain via the caller field; RunSpace only needs to hold a reference to the current active frame and continue executing the next frame after that one exits, until state is exited or suspended.
In implementation, the RunSpace class implements the Runnable interface, so it can be directly submitted to any Java executor (thread pools, event loops like Netty's EventLoop, virtual threads, custom schedulers, etc.). Core code:
class RunSpace implements Runnable{
protected CallFrame<?> currCallFrame;
protected Instance<?> exception;
protected ResultSlots resultSlots = new ResultSlots(); // shared result store for callframes in this RunSpace
protected byte runningState = RunningState.PENDING;
public void run() {
this.setRunningState(RunningState.RUNNING);
while (this.currCallFrame != null &&
!RunningState.isPausingOrWaitingResult(this.getRunningState())) {
this.currCallFrame.run(); // execute VM instructions frame by frame
}
tryComplete();
}
}RunSpace's run method loops calling the current frame's run() until all frames complete or are suspended; then cleanup logic executes.
In ago, function calls maintain the same syntactic form as traditional programming languages: f(arguments). But from an implementation perspective, it's actually split into two steps: first create a CallFrame object, then execute that CallFrame. Expressed in two lines of code:
var t = new f(arguments) // generate a CallFrame object and put it in slot t
t() // run that CallFrame
You can consider f(arguments) as syntactic sugar for the above creator and invoke statements. When you need to directly use the CallFrame object, split them apart. Also, fork statements similarly return a CallFrame object.
Below is the VM instruction sequence produced after compiling the simplest addition function:
fun add(a as int, b as int) as int{
return a + b
}
fun main(args as string){
var t = add(5, 4)
Trace.print(t)
}
main compiles to the following vm code:
// for statement `var t = add(5, 4)`
0 new_vC 2,15 // create instance of `add`, put in slot 2; 15 is class id of `add`
3 const_fld_i_ovc 2,0,5 // set parameter 1 of `add` to 5, slot 0
7 const_fld_i_ovc 2,1,4 // set parameter 2 of `add` to 4, slot 1
11 invoke_v 2 // invoke slot 2. runspace.currCallFrame set to instance of `add`, and `main.run` exits so that runspace shifts to `add` callframe and calls `add.run()` to evaluate opcode of `add`
13 accept_i_v 1 // after `add` invoked, runSpace shifts back to `main` and resumes `main.run` from here; this instruction copies result at `runspace.resultSlots` to slot 2 of `main` callframe, equals `slots.setInt(1, runspace.resultSlots.getInt())`
add compiles to:
0 add_i_vvv 2,0,1 // invoke add instruction, result put in slot 2
4 return_i_v 2 // runspace.resultSlots.setInt(slots.getInt(2)), and set `this.caller.runspace.currFrame` to `this.caller`; here runspace.currCallFrame restores to the instance of `main`
As you can see, RunSpace running CallFrames via function calls is implemented through iteration on currCallFrame. When main calls add, main's callframe switches currCallFrame to add's callframe; at this point main's callframe is suspended but remains in memory. After add finishes computing, it writes the result into runspace.resultSlots and restores currCallFrame back to the original main callFrame. Then main executes accept_i_v, copying the call result back into main's slot. The entire process matches coroutine suspend/resume semantics: caller "yields", callee activates; afterward callee exits, caller resumes.
Because CallFrames have complete execution context, state registers, and other information meeting coroutine specifications, they can serve as coroutines, laying the foundation for subsequent concurrent programming.
Native functions map Java runtime functions to ago, becoming ago functions.
The difference between native functions and normal functions is that the function body is native "Java function name".
For example:
fun getClass() as Class native "org.siphonlab.ago.lang.Lang.Object_getClass";
Native functions are limited to public static void functions.
On the Java side, the corresponding function form is:
public static void function(NativeFrame frame, arguments){}| Ago Method Signature | Java Native Method Signature (example) | Description |
|---|---|---|
fun foo(x as int) as int |
public static void Foo_foo(NativeFrame frame, int x) |
First parameter is NativeFrame, followed by all call arguments. |
fun bar() as string |
public static void Bar_bar(NativeFrame frame) |
Only frame parameter; return value set via frame.finishString(...). |
fun baz() as void |
public static void Baz_baz(NativeFrame frame) |
Same, call frame.finishVoid() to return. |
The NativeFrame object is the currently running CallFrame object — a subclass of CallFrame.
- If the function is a method of some class, access the method's
Instanceviaframe.getParentScope(). - Synchronously write return values back to VM via
frame.finish*(). - Start async via
frame.beginAsync(), return async call results viaframe.finishXxxAsync.
| ago type | Java corresponding type |
|---|---|
int, long, float, double, boolean
|
Native Java primitive types |
string |
java.lang.String |
Object (or concrete class) |
Corresponding Instance<?>
|
classref |
int (internal class id) |
Example
fun Integer.toHexString(v as int) as string native "org.siphonlab.ago.lang.Integer_toHexString";Corresponding Java:
public static void Integer_toHexString(NativeFrame frame, int number){ frame.finishString(Integer.toHexString(number)); }
-
frame.finishInt(value)→ RunSpace's return slot filled with integer. -
frame.finishString(str)→ Filled with string. -
frame.finishObject(objInstance)→ Filled with object instance.
Runtime Loading
Native functions are mapped to the AgoNativeFunction class (AgoNativeFunction extends AgoFunction extends AgoClass). In the Class Loader, Java bytecode is generated for this call, converting the native entrance function into a Java implementation of interface NativeFunctionCaller — mainly implementing its invoke method to extract parameter values from NativeFrame Slots and fill them into the entrance function.
When generating CallFrame objects, for native functions a NativeFrame is generated and placed in RunSpace for execution; RunSpace executing NativeFrame.run() triggers the NativeFunctionCaller object's invoke method.
Typical Implementation (from
org.siphonlab.ago.lang.Lang)
public static void Object_hashCode(NativeFrame frame){
frame.finishInt(frame.getParentScope().hashCode());
}-
frame.getParentScope()returns a reference to the caller (Instance). -
finishIntwrites the result back to VM.
sleep function implementation:
fun sleep(milliseconds as int) native "org.siphonlab.ago.lang.RunSpaceAware.sleep"
public static void sleep(NativeFrame nativeFrame, int millisecond) {
var runSpaceHost = nativeFrame.getRunSpace().getRunSpaceHost();
nativeFrame.beginAsync(); // set runspace state to RunningState.WAITING_RESULT
runSpaceHost.setTimer(millisecond, nativeFrame::finishVoidAsync); // set callback; when finishVoidAsync is called, runspace.run() restores `nativeFrame.caller` and resumes executing
// NativeFrame.run completes, runspace state is WAITING_RESULT, runspace.run temporarily exits
}On call, just use it like a normal function: sleep(2000).
Through beginAsync and finishVoidAsync, NativeFrame transforms a callback-style Java async program into a normal ago function.
If you need to throw ago exceptions, use finishException and finishExceptionAsync.
The only difference between native classes and normal class declarations is placing the native modifier before class.
Native classes don't map Java types; their purpose is creating NativeInstance objects when instantiating, whereas normal classes create Instance objects.
NativeInstance has an Object nativePayload attribute that can store objects inconvenient to place in Slots. For example:
native class ArrayList<E> from List<E>{
fun new(){
init()
}
private fun init() native "org.siphonlab.ago.lang.ArrayList.create";
}
Here, org.siphonlab.ago.lang.ArrayList.create places a Java ArrayList object in nativePayload:
package org.siphonlab.ago.lang;
public class ArrayList{
public static void create(NativeFrame callFrame) {
NativeInstance instance = (NativeInstance) callFrame.getParentScope();
GenericArgumentsInfo genericArgumentsInfo = (GenericArgumentsInfo) instance.getAgoClass().getConcreteTypeInfo();
TypeInfo typeInfo = genericArgumentsInfo.getArguments()[0];
var ls = switch (typeInfo.getTypeCode().value) {
case INT_VALUE -> new IntArrayList();
case LONG_VALUE -> new LongArrayList();
case FLOAT_VALUE -> new FloatArrayList();
case DOUBLE_VALUE -> new DoubleArrayList();
case BOOLEAN_VALUE -> new BooleanArrayList();
case STRING_VALUE -> new java.util.ArrayList<String>();
case SHORT_VALUE -> new ShortArrayList();
case BYTE_VALUE -> new ByteArrayList();
case CHAR_VALUE -> new CharArrayList();
case OBJECT_VALUE -> new java.util.ArrayList<Instance<?>>();
case CLASS_REF_VALUE -> new IntArrayList();
default -> throw new IllegalArgumentException("unknown type: %s".formatted(typeInfo.getTypeCode()));
};
instance.setNativePayload(ls);
callFrame.finishVoid();
}
}When objects need to serialize Slots, don't forget to serialize nativePayload.
Similarly, NativeFrame has this design — async native functions often need to store a payload. Additionally, for restart-and-resume scenarios, placing idempotent state information in the NativeFrame's payload is also important.