10 - Advanced Features

This chapter covers Verge’s more advanced tools. Some are high-level conveniences; others are lower-level and give you fine-grained control. A capable language needs both.

Traits

A trait describes shared behaviour as a set of method signatures, optionally with default implementations. Traits in Verge are structural (Go-style): a type satisfies a trait simply by providing methods with matching names and signatures — there’s no implements declaration to write. Use a trait as a parameter type and Verge dispatches dynamically.

import std.io

Shape: trait {
	area: f() => f64
	name: f() => string = "shape"   # a default; types may override it
}

Circle: object {
	R: f64
	area: f() => f64 = 3.14159 * .R * .R
	name: f() => string = "circle"
}

Square: object {
	Side: f64
	area: f() => f64 = .Side * .Side   # no `name` — inherits the default
}

describe: f(s: Shape) => string = "{s.name()}: {s.area():.2}"

main: f() {
	io.writeln(describe(Circle{R: 2.0}))
	io.writeln(describe(Square{Side: 3.0}))
}
circle: 12.57
shape: 9.00

Both Circle and Square satisfy Shape because they provide area (and Circle also overrides name), so both can be passed wherever a Shape is expected.

Generics

Generics let one definition serve many types. A type parameter is written in angle brackets with an optional constraint:

import std.io

identity: f<T: any>(val: T) => T = val

main: f() {
	io.writeln(identity<i32>(42))
	io.writeln(identity<string>("verge"))
}
42
verge

T: any allows any type; you can constrain instead to a trait (T: Shape) or a numeric union (T: Numeric) when the body needs specific behaviour. Objects and enums can be generic too — Stack<T>, Option<T>, Result<T, E> are all ordinary generic types.

Iterators and yield

An iterator function produces values lazily, one at a time, instead of building the whole result up front. It yields each value and resumes where it left off:

import std.io

count: iterator(max: u8) => u8 {
	for i := 0..max {
		yield i
	}
}

main: f() {
	for n := count(5) {
		io.write("{n} ")
	}
	io.writeln("")
}
0 1 2 3 4 

Iterators are ideal for streaming work and lazy pipelines — you drive them with an ordinary for loop.

Extension methods

You can attach methods to any existing type, including the built-ins. Inside the method, . is the receiver:

import std.io

string.shout: f() => string = .to_upper() + "!"
i32.is_positive: f() => bool = . > 0

main: f() {
	io.writeln("hello".shout())
	io.writeln("{(5).is_positive()}")
}
HELLO!
true

Extension methods are one of Verge’s more distinctive features and are useful for adding focused helpers to types you don’t own.

Operator overloading

If a type has a sensible operator meaning, define it with operator:

import std.io

Vec2: object {
	X: f64
	Y: f64
}

operator +(a, b: Vec2) => Vec2 = Vec2{X: a.X + b.X, Y: a.Y + b.Y}

main: f() {
	c := Vec2{X: 1.0, Y: 2.0} + Vec2{X: 3.0, Y: 4.0}
	io.writeln("({c.X}, {c.Y})")
}
(4, 6)

Operator overloading is powerful, so use it where the meaning is genuinely clear and reach for named functions when it isn’t.

@derive

@derive generates common behaviour for an object so you don’t hand-write it. Eq gives you ==, Debug gives you a debug_string(), Hash makes the type usable as a map key:

import std.io

@derive(Debug, Eq, Hash)
Point: object {
	X: f64
	Y: f64
}

main: f() {
	io.writeln("{Point{X: 3.0, Y: 4.0} == Point{X: 3.0, Y: 4.0}}")
	io.writeln(Point{X: 3.0, Y: 4.0}.debug_string())
}
true
Point{X: 3, Y: 4}

Macros

Macros are compile-time functions that build code. An expression macro returns an ast.expr that’s spliced in at the call site; a declaration macro returns []ast.decl to generate whole declarations. They use the ast.* API to construct syntax trees.

Here’s a genuinely useful one — a Go-style make that builds a zero-filled slice of any type:

import std.io

## Builds a slice of type `t` (e.g. `[][]f64`) holding `n` zero-valued elements.
make: macro(t: ast.type, n: i32) => ast.expr {
	elem := ast.type.element(t)
	ast.slice_repeat(t, ast.zero_value(elem), ast.int(n))
}

main: f() {
	rows := make([][]f64, 3)
	counts := make([]i32, 5)
	io.writeln("rows: {rows.length()}, counts: {counts.length()}")
}
rows: 3, counts: 5

Macros are a powerful tool for eliminating boilerplate and generating code, and are best used deliberately where hand-writing the equivalent would be repetitive or error-prone.

Attributes

An attribute is a decorator-style compile-time hook applied with @name. It receives the declaration it’s attached to and can inspect it, emit compile-time diagnostics, or transform it:

import std.io

log_decl: attribute(decl: ast.decl) {
	ast.compile_warning("declaration: " + decl.name)
}

@log_decl
greet: f(name: string) => string = "Hello, " + name

main: f() {
	io.writeln(greet("Ada"))
}

Compiling this prints a build-time warning ([macro warning] declaration: greet) and then the program runs normally:

Hello, Ada

Testing

Tests live inline in .v files as test blocks, which can nest to group related cases. assert checks a condition, with an optional message:

add: f(a, b: i32) => i32 = a + b

test "math" {
	test "addition" {
		assert(add(1, 1) == 2)
	}
	test "subtraction" {
		assert(5 - 3 == 2, "5 - 3 should equal 2")
	}
}

main: f() {}

Run them with verge test:

ok  math/addition
ok  math/subtraction

2 passed, 0 failed

Filter with verge test "math" or drill in with verge test "math > addition". There’s more on the test runner in Tooling.

Concurrency

Verge has both OS threads and coroutines, along with structured tools for coordinating concurrent work safely.

Spawning work

spawn starts an OS thread; do starts a coroutine. Both return a future you wait() on, and wait() returns (result, error) so failures propagate with !!:

import std.io

compute: f(n: i32) => i32 = n * n
fetch: f() => string = "data"

main: f() {
	t := spawn compute(7)
	co := do fetch()

	r := t.wait()!!
	s := co.wait()!!
	io.writeln("computed {r}, fetched {s}")
}
computed 49, fetched data

Structured concurrency with group

A group block doesn’t exit until every task spawned inside it has finished — no manual list of futures to join. Name pipes after closes and they’re closed automatically when the group exits:

import std.io

worker: f(id: i32, out: pipe<i32>) {
	out <- id * 10
}

main: f() {
	results := pipe.new<i32>(3)   # buffered so the workers don't block
	group closes results {
		for i := 0..3 {
			spawn worker(i, results)
		}
	}
	total := 0
	for v := results {
		total += v
	}
	io.writeln("total {total}")
}
total 30

Shared state: atomic and synced

atomic gives a value lock-free atomic operations; synced wraps it in an automatic mutex. Here eight threads bump the same atomic counter, and the total is always exactly 8:

import std.io

main: f() {
	hits: atomic i32 = 0
	inc := f() {
		hits += 1
	}
	group {
		for i := 0..8 {
			spawn inc()
		}
	}
	io.writeln("hits: {hits}")
}
hits: 8

Pipes and select

Pipes are typed, thread-safe channels — pipe.new<T>() for unbuffered (send blocks until a receiver is ready) or pipe.new<T>(capacity) for buffered. Send and receive both use <-, and select waits on whichever case is ready first, with an optional after timeout (nanoseconds):

import std.io

main: f() {
	p := pipe.new<i32>(1)
	p <- 42
	select {
		v := <- p {
			io.writeln("got {v}")
		}
		after 50_000_000 {
			io.writeln("timed out")
		}
	}
}
got 42

A pipe can be iterated with for value := p { ... }, which drains it and exits when the pipe is closed.

Foreign functions (FFI)

Verge talks to C directly. A link block names the library and header and declares the functions you want, which you then call like any other Verge function:

import std.io

link "m" extern from "math.h" {
	sqrt: f(x: f64) => f64
}

main: f() {
	io.writeln("sqrt(16) = {sqrt(16.0)}")
}
sqrt(16) = 4

For anything larger than a couple of functions, verge bind generates these declarations straight from a C header, which is far more reliable than transliterating a large header by hand. See Tooling for verge bind.

That concludes the advanced-features tour. You can go a long way with just traits, generics, and errors; the rest is here for when the problem genuinely calls for it.