Part II: Functions and Objects#
In this part:
- Functions and Abilities - Function declaration, parameters, abilities
- Object-Oriented Programming - Objects, inheritance, enums
- Implementations and Forward Declarations - Impl blocks, separation of interface
This part covers Jac's approach to functions and object-oriented programming. Jac uses def for standalone functions and can for methods (called "abilities") on objects. The key difference from Python: has declarations make your data model explicit, and impl blocks let you separate interface from implementation.
Functions and Abilities#
Functions in Jac use familiar def syntax with mandatory type annotations. Jac also introduces "abilities" (can) for methods attached to nodes, edges, and walkers. Abilities can be triggered automatically based on context (like when a walker visits a node) rather than being called explicitly.
1 Function Declaration#
def add(a: int, b: int) -> int {
return a + b;
}
def greet(name: str) -> str {
return f"Hello, {name}!";
}
# No return value
def log(message: str) -> None {
print(f"[LOG] {message}");
}
2 Docstrings#
Docstrings appear before declarations (not inside like Python):
"""Module-level docstring."""
"Function docstring."
def add(a: int, b: int) -> int {
return a + b;
}
"Object docstring."
obj Person {
has name: str;
}
3 Parameter Types and Ordering#
Parameter Categories:
| Category | Syntax | Description |
|---|---|---|
| Positional-only | Before / |
Must be passed by position |
| Positional-or-keyword | Normal params | Can be passed either way |
| Variadic positional | *args |
Collects extra positional args |
| Keyword-only | After * or *args |
Must be passed by keyword |
| Variadic keyword | **kwargs |
Collects extra keyword args |
Required Parameter Order:
def complete_example(
pos_only1: int, # 1. Positional-only parameters
pos_only2: str,
/, # 2. Positional-only marker
pos_or_kw: float, # 3. Normal (positional-or-keyword)
with_default: int = 10, # 4. Parameters with defaults
*args: int, # 5. Variadic positional
kw_only: str, # 6. Keyword-only (after * or *args)
kw_default: bool = True, # 7. Keyword-only with default
**kwargs: any # 8. Variadic keyword (must be last)
) -> None {
print("called");
}
Positional-only parameters (/):
def greet(name: str, /) -> str {
return f"Hello, {name}!";
}
with entry {
greet("Alice"); # OK
# greet(name="Alice"); # Error: positional-only
}
Keyword-only parameters (after *):
def configure(*, host: str, port: int = 8080) -> None {
print(f"Connecting to {host}:{port}");
}
with entry {
configure(host="localhost"); # OK
# configure("localhost", 8080); # Error: keyword-only
configure(host="localhost", port=443); # OK
}
Variadic parameters:
# *args collects extra positional arguments
def sum_all(*values: int) -> int {
return sum(values);
}
# **kwargs collects extra keyword arguments
def build_config(**options: object) -> dict {
return dict(options);
}
# Combined
def flexible(required: int, *args: int, **kwargs: object) -> None {
print(f"Required: {required}");
print(f"Extra positional: {args}");
print(f"Extra keyword: {kwargs}");
}
with entry {
sum_all(1, 2, 3, 4, 5); # 15
build_config(debug=True, verbose=False); # {"debug": True, "verbose": False}
flexible(1, 2, 3, name="test");
# Required: 1
# Extra positional: (2, 3)
# Extra keyword: {"name": "test"}
}
Unpacking arguments:
def add(a: int, b: int, c: int) -> int {
return a + b + c;
}
with entry {
# Unpack list/tuple into positional args
values = [1, 2, 3];
result = add(*values); # add(1, 2, 3)
# Unpack dict into keyword args
params = {"a": 1, "b": 2, "c": 3};
result = add(**params); # add(a=1, b=2, c=3)
# Combined unpacking
result = add(*[1, 2], **{"c": 3}); # add(1, 2, c=3)
}
4 can vs def#
Jac has two keywords for defining callable behavior: def for standard functions/methods and can for event-driven abilities on archetypes. Use def when you want explicit calling; use can when behavior should trigger automatically based on walker/node context.
| Feature | def |
can |
|---|---|---|
| Call style | Called explicitly: obj.method() |
Triggered automatically on walker entry/exit |
| Used in | Any archetype, standalone functions | Walkers, nodes, edges |
| Syntax | def name(args) -> Type { } |
can name with NodeType entry { } |
| Best for | Regular methods, utility functions, API endpoints | Graph traversal logic, event handlers |
walker ListItems {
has items: list = [];
# 'can' ability -- fires automatically when walker enters a Root node
can collect with Root entry {
visit [-->];
}
# 'can' ability -- fires on each Item node visited
can gather with Item entry {
self.items.append(here.value);
}
# 'can' ability -- fires when walker exits Root
can report_all with Root exit {
report self.items;
}
}
See Part III: OSP for complete walker and ability documentation.
5 Methods#
The def keyword declares methods on archetypes:
obj Calculator {
has total: float = 0.0;
def add(value: float) -> float {
self.total += value;
return self.total;
}
def reset() -> None {
self.total = 0.0;
}
}
6 Static Methods and Class Methods#
Jac provides three method modifiers: def (instance method, receives self), static def (no receiver), and class def (class method, receives Self).
obj Counter {
static has count: int = 0;
# Static method -- no self or cls, pure utility
static def get_count() -> int {
return Counter.count;
}
# Class method -- Self refers to the class itself
class def create() -> Self {
return Self();
}
# Instance method -- self is the instance
def increment() -> None {
Counter.count += 1;
}
}
| Modifier | Receiver | Use case |
|---|---|---|
def |
self (implicit) |
Instance behavior |
static def |
none | Utility functions |
class def |
Self (implicit) |
Factory methods, alternative constructors |
Class Methods and Self#
class def declares a class method. Inside it, Self (capital S) refers to the class itself -- equivalent to Python's cls parameter, but auto-injected.
obj Animal {
has name: str,
sound: str = "...";
class def create(name: str) -> Self {
return Self(name=name);
}
def speak() -> str {
return f"{self.name} says {self.sound}";
}
}
obj Dog(Animal) {
has sound: str = "woof";
}
with entry {
# Self resolves to Dog, not Animal -- polymorphic!
d = Dog.create("Rex");
print(d.speak()); # Rex says woof
print(type(d).__name__); # Dog
}
Self is polymorphic: when a subclass inherits a class def, Self resolves to the subclass. This makes factory methods work correctly across inheritance hierarchies without overriding.
Self as a Type Annotation#
Self also works as a type annotation in instance methods, enabling fluent/builder patterns:
obj QueryBuilder {
has table: str = "",
limit_val: int = -1;
def from_table(table: str) -> Self {
self.table = table;
return self;
}
def limit(n: int) -> Self {
self.limit_val = n;
return self;
}
}
with entry {
q = QueryBuilder().from_table("users").limit(10);
}
| Context | Self resolves to |
|---|---|
class def body |
the class (cls in Python) |
def body |
type(self) -- the runtime class |
| Type annotation | the enclosing class name |
7 Lambda Expressions#
# Simple lambda (note spacing around type annotations)
glob add = lambda a: int , b: int -> int : a + b;
# Lambda with block
glob process = lambda x: int -> int {
result = x * 2;
result += 1;
return result;
};
# Lambda without parameters
glob get_value = lambda : 42;
# Lambda with return type only
glob get_default = lambda -> int : 100;
# Lambda with default parameters
glob power = lambda x: int = 2 , y: int = 3 : x ** y;
# Using lambdas
glob numbers = [1, 2, 3, 4, 5];
glob squared = list(map(lambda x: int : x ** 2, numbers));
glob evens = list(filter(lambda x: int : x % 2 == 0, numbers));
# Lambda returning lambda
glob make_adder = lambda x: int : (lambda y: int : x + y);
glob add_five = make_adder(5); # add_five(10) returns 15
8 Immediately Invoked Function Expressions (IIFE)#
9 Decorators#
def decorator(func: object) -> object {
return func;
}
def decorator_with_args(arg1: object, arg2: object) -> object {
return lambda func: object: func;
}
@decorator
def my_function -> None {
print("decorated");
}
@decorator_with_args("a", "b")
def another_function -> None {
print("decorated with args");
}
10 Access Modifiers#
# Public (default, accessible everywhere)
def:pub public_func -> None { }
# Private (accessible only within the module)
def:priv _private_func -> None { }
# Protected (accessible within module and subclasses)
def:protect _protected_func -> None { }
Try it: Functions complete example
def greet(name: str) -> str {
return f"Hello, {name}!";
}
def add(a: int, b: int) -> int {
return a + b;
}
def apply(func: Callable[[int, int], int], x: int, y: int) -> int {
return func(x, y);
}
with entry {
print(greet("World"));
print(add(3, 4));
print(apply(lambda x: int, y: int -> int { return x * y; }, 5, 6));
}
Object-Oriented Programming#
Jac uses obj instead of class to define types (though class is also supported for Python compatibility). The key differences from Python: fields are declared with has at the top of the definition, methods use can instead of def, and there's no explicit __init__ -- the constructor is generated automatically from has declarations.
1 Objects (Classes)#
Objects are Jac's basic unit of data and behavior. Use obj for general-purpose types. For graph-based programming, use node, edge, or walker instead (see Part III: OSP).
When to use obj vs class
Jac's obj enforces stricter semantics than Python's class -- fields are declared upfront with has, constructors are auto-generated, and the structure is designed to be portable across codespaces (server, client, native). This strictness is intentional: it enables the compiler to target multiple execution environments from the same source code.
If you need Python-specific class machinery like metaclasses or @property decorators, use a Python class instead. Jac provides static def for static methods, class def for class methods (with Self), and static has for class-level fields.
obj Person {
has name: str;
has age: int;
def postinit() -> None {
# Called after the auto-generated init completes
print(f"Created {self.name}");
}
def greet() -> str {
return f"Hi, I'm {self.name}";
}
}
with entry {
# Usage
person = Person(name="Alice", age=30);
print(person.greet());
}
2 Inheritance#
obj Animal {
has name: str;
def speak() -> str {
return ""; # Base implementation
}
}
obj Dog(Animal) {
has breed: str = "Unknown";
override def speak() -> str {
return "Woof!";
}
}
obj Cat(Animal) {
override def speak() -> str {
return "Meow!";
}
}
# Trackable mixin
obj Trackable {
has tracked: bool = False;
}
# Multiple inheritance
# Note: When a parent has fields with defaults, all child fields
# must also have defaults (Python dataclass constraint)
obj Pet(Animal, Trackable) {
has owner: str = "";
has name: str = "";
}
3 Enumerations#
enum Color {
RED = "red",
GREEN = "green",
BLUE = "blue"
}
# With auto values
enum Status {
PENDING,
ACTIVE,
COMPLETED
}
with entry {
# Usage
color = Color.RED;
status = Status.ACTIVE;
print(f"Color: {color}, Status: {status}");
}
4 Enums with Inline Python#
enum HttpStatus {
OK = 200,
NOT_FOUND = 404
::py::
def is_success(self):
return 200 <= self.value < 300
@property
def message(self):
return {200: "OK", 404: "Not Found"}.get(self.value, "Unknown")
::py::
}
5 Properties and Encapsulation#
obj Account {
has:priv _balance: float = 0.0;
def get_balance() -> float {
return self._balance;
}
def deposit(amount: float) -> None {
if amount > 0 {
self._balance += amount;
}
}
}
Try it: Objects and Enums complete example
enum Color {
RED = "red",
GREEN = "green",
BLUE = "blue"
}
obj Animal {
has name: str,
sound: str;
def speak() -> str {
return f"{self.name} says {self.sound}!";
}
}
obj Dog(Animal) {
has breed: str;
def speak() -> str {
return f"{self.name} the {self.breed} says {self.sound}!";
}
}
with entry {
dog = Dog(name="Rex", sound="woof", breed="Labrador");
print(dog.speak());
print(f"Favorite color: {Color.BLUE.value}");
}
Implementations and Forward Declarations#
Jac separates interface (what an object has and can do) from implementation (how it does it). This separation enables cleaner architecture, easier testing, and better organization of large codebases. You declare the interface in one place and implement abilities in impl blocks -- even in separate files.
1 Forward Declarations#
Forward declarations let you reference a type before it's fully defined. This is essential for circular references (like User referencing Post and Post referencing User) and for organizing code across multiple files.
# Forward declarations
obj User;
obj Post;
# Now define with mutual references
obj User {
has name: str;
has posts: list[Post] = [];
}
obj Post {
has content: str;
has author: User;
}
2 Implementation Blocks#
The impl keyword attaches method bodies to declared abilities. This pattern keeps your interface clean and readable while moving implementation details elsewhere. It's particularly useful for large classes, for providing multiple implementations (like mock versions for testing), or for organizing abilities that span many lines.
# Interface (declaration)
obj Calculator {
has value: float = 0.0;
def add(x: float) -> float;
def multiply(x: float) -> float;
}
# Implementation
impl Calculator.add {
self.value += x;
return self.value;
}
impl Calculator.multiply {
self.value *= x;
return self.value;
}
3 Separate Implementation Files#
Convention: Use .impl.jac files for implementations.
calculator.jac:
obj Calculator {
has value: float = 0.0;
def add(x: float) -> float;
def multiply(x: float) -> float;
}
calculator.impl.jac:
impl Calculator.add {
self.value += x;
return self.value;
}
impl Calculator.multiply {
self.value *= x;
return self.value;
}
4 Variant Modules#
A single logical module can be split across variant files that target different execution contexts. Variant suffixes are .sv.jac (server), .cl.jac (client), and .na.jac (native). All files sharing the same base name are automatically discovered and compiled together.
Head module precedence: .jac > .sv.jac > .cl.jac > .na.jac. The highest-precedence file that exists on disk becomes the head module; all lower-precedence variants are attached as variant annexes. If no plain .jac file exists, the next available variant acts as head.
mymod/
├── mymod.jac # Head module (declarations + entry)
├── mymod.sv.jac # Server variant (extra server-only declarations)
├── mymod.cl.jac # Client variant (extra client-only declarations)
├── mymod.impl.jac # Head implementations (can also impl variant decls)
├── impl/
│ └── mymod.sv.impl.jac # Server variant impl (from shared folder)
└── mymod.test.jac # Tests
Each variant gets its own symbol table during parsing. The compiler then connects declarations and implementations across all variants:
- Impl files match their variant automatically (e.g.,
mymod.sv.impl.jacprovides bodies for declarations inmymod.sv.jac). - A head impl file (
mymod.impl.jac) can provide implementations for declarations in any variant (cross-variant matching). - Impl files can live in the same directory or in an
impl/subdirectory.
mymod.jac:
mymod.sv.jac:
mymod.cl.jac:
mymod.impl.jac (cross-variant -- provides impls for both head and client variant):
impl Circle.area -> float {
return 3.14159 * self.radius * self.radius;
}
impl Display.render -> str {
return "Displaying: " + self.label;
}
impl/mymod.sv.impl.jac (server variant impl from shared folder):
Native Variant Files (.na.jac)#
Native variant files compile to LLVM IR and execute via JIT (MCJIT). Code in .na.jac files runs as native machine code, bypassing the Python runtime entirely. This is useful for performance-critical code and for calling C libraries directly. The same functionality is available inside na {} blocks in regular .jac files.
C Library Imports:
Native code can import C shared libraries using the import from syntax with a library path and extern function declarations, either at the top level of a .na.jac file or inside a na {} block:
# math_native.na.jac
import from "/usr/lib/libm.so.6" {
def sqrt(x: f64) -> f64;
def pow(base: f64, exp: f64) -> f64;
}
def hypotenuse(a: f64, b: f64) -> f64 {
return sqrt(a * a + b * b);
}
Declarations inside the braces are body-less function signatures that become LLVM declare (extern) statements. The shared library is loaded automatically at JIT time, and symbols are resolved by name.
Type mapping: Jac's int maps to i64 and float maps to f64 in native code. Use fixed-width types (i8, i16, i32, u8, u16, u32, f32, etc.) when C functions expect specific sizes. The compiler automatically coerces between standard and fixed-width types at call boundaries.
Example -- calling raylib from Jac:
# game.na.jac
import from "libraylib.so" {
def InitWindow(width: i32, height: i32, title: str) -> None;
def CloseWindow() -> None;
def WindowShouldClose() -> i8;
def BeginDrawing() -> None;
def EndDrawing() -> None;
def ClearBackground(color: i32) -> None;
def DrawText(text: str, x: i32, y: i32, size: i32, color: i32) -> None;
}
with entry {
InitWindow(800, 600, "Hello from Jac");
while WindowShouldClose() == 0 {
BeginDrawing();
ClearBackground(0);
DrawText("Jac + Raylib", 300, 280, 30, -1);
EndDrawing();
}
CloseWindow();
}
5 When to Use Implementations#
- Circular dependencies: Forward declare to break cycles
- Code organization: Keep interfaces clean
- UI components: Separate render tree from method logic (
.cl.jac+.impl.jac) - Plugin architectures: Define interfaces that plugins implement
- Large codebases: Separate concerns across files
- Variant modules: Split server, client, and native code into separate files while keeping them as one logical module
- C interop: Use
.na.jacfiles to call C libraries directly from JIT-compiled native code
Learn More#
Tutorials:
- Jac Basics - Objects, functions, and syntax
- Testing - Write tests for your code
Related Reference:
- Part I: Foundation - Variables, types, control flow
- Part III: OSP - Nodes, edges, walkers