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.