d6 docs

Classes, modules, and methods

Language reference · Previous: Functions, closures, and calls · Next: Gradual typing and exceptions

Constants

Uppercase names assigned at the top level or in a class or module define shared, write-once bindings:

DEFAULT_LIMIT = 100
class Config
  LIMIT = 20
  def limit() = LIMIT
  def self.default_limit() = DEFAULT_LIMIT
end
puts(Config::LIMIT)

Functions and methods can read constants. Unqualified lookup checks the current class or module, its enclosing namespaces, the ancestors of the current class or module (superclasses and included modules), then the top level. A namespace's constant shadows one with the same name outside it, and an enclosing namespace's constant beats an inherited one:

class Shape
  SIDES = 0
end
class Triangle < Shape
  def sides() = SIDES   # inherited: 0
end
puts(Triangle::SIDES)   # 0

A subclass can redefine an inherited constant for itself (SIDES = 3); that does not change the superclass's. Use Config::LIMIT to access a constant explicitly, including from other files loaded with require. Top-level constants are shared by the loaded program, not private to a file.

Constants may be referenced by methods defined before the constant's assignment, but the assignment must execute before the value is read. Reading an uninitialized constant raises RuntimeError. Executing the same constant assignment more than once also raises RuntimeError. Reassigning a constant is an error, and constants cannot be defined inside functions. Arrays and hashes held by constants are not frozen: their contents remain mutable.

Classes

class Named
  def initialize(name)
    @name = name
  end
  def render(prefix)
    prefix + @name
  end
end

class LoudNamed < Named
  def render(prefix)
    self.name() + prefix
  end
end

@ivar instance fields, initialize as the constructor, single inheritance via <, and super(...). Inside an instance method, self. is optional when calling another method of the same object: render_row(item) means self.render_row(item) when the class, a superclass, or an included module has a render_row method, including a private one. A top-level function of the same name takes precedence.

A program can reopen a built-in (prelude) class or module to add to it, the same as its own: class Range ... def middle() ... end or module Math ... def self.double(x) = x * 2 ... end. Classes are compile-time metadata, not first-class heap values, with two narrow exceptions: ClassName.redefine_method(name, callable) repoints an existing method's compiled body at runtime (the callable may come from compile_method, as long as its arity matches), and ClassName.define_method(name, callable) adds a brand-new method under a name the class didn't already have. Both take the same "patch-factory" shaped callable -- a nested, named function (d6 has no anonymous closure literal) that captures no variables and was compiled inside the same class, typically returned from a def self.x_factory() that declares the nested def and then evaluates to its bare name:

class Greeter
  def initialize(name)
    @name = name
  end
  def self.greet_factory()
    def greet()
      "hello, #{@name}"
    end
    greet
  end
end

g = Greeter.new("Ada")
Greeter.define_method("greet", Greeter.greet_factory())
g.greet()  # => "hello, Ada"

define_method fails if the name already exists (use redefine_method for that) or if the callable captures a variable, isn't a method of ClassName itself, or the class already has the maximum number of methods. Unlike redefine_method, there's no arity to match against a prior definition -- the new method simply takes the callable's own arity, the same as an ordinary def would. The new method is visible to every instance immediately, including ones already constructed before the call, since dispatch looks the method up by class and name at call time rather than snapshotting anything at construction time.

Inside a class's own def self.x method, self.new(...) builds an instance of whichever class self is (so an inherited factory builds the subclass it was called on; a sealed class still refuses), and self.define_method, self.redefine_method, and self.compile_method act on whichever class self is at run time -- so a base class can generate methods for each subclass it's called on:

class Record
  def self.fields(names)
    names.each() do |name|
      self.define_method(name, self.compile_method(name, [], "self.data()[key]", {"key": name}))
    end
  end
end
class Book < Record
end
Book.fields(["title"])   # only Book gains #title

compile_method

ClassName.compile_method(name, params, body_source, bound_values) closes the one gap define_method leaves: its callable normally has to be an already-compiled nested def, physically written in the source, so there's no way to build a method body from a runtime string. compile_method does that -- it compiles body_source as if it were a method on ClassName and returns a Callable ready to hand to define_method, the same as the factory idiom above:

class Greeter
  attr_accessor name: String

  def initialize(name: String)
    @name = name
  end
end

class Holder[T]
  # A generic class's annotations can name its parameters; each instance's
  # own binding decides what the writer accepts.
  attr_accessor value: T

  def initialize(value: T)
    @value = value
  end
end

callable = Greeter.compile_method("greeting", ["prefix"], "prefix + self.name()", {})
Greeter.define_method("greeting", callable)

Greeter.new("Ada").greeting("Hello, ")  # => "Hello, Ada"

The name may end in =, ?, or ! like any def ("title=" generates a writer that record.title = value calls). params is a plain list of parameter names (Strings) -- no types, defaults, splats, or block parameters. body_source can reference self, call other methods on it, and read/write the class's existing @fields, but it can't grow the class's field layout: referencing a field the class doesn't already have fails with ArgumentError before anything is installed, as does a syntax error in body_source. It also can't name another class directly (body_source compiles in its own isolated program, which doesn't know any class but ClassName exists) or close over the calling scope's locals -- bound_values, a Hash of already-evaluated values (capped at 8 entries), is how you thread those in instead; they're spliced on as trailing parameters the installed method's own callers never supply:

callable = Other.compile_method("boxed_label", [], "formatter.wrap(self.label())",
  {"formatter": formatter})

Out of scope: self.-owned singleton methods (instance methods only) and any sandboxing -- body_source runs as ordinary compiled bytecode with full language access, the same trust level Ruby's own class_eval/define_method assume. Meant for programmer-authored metaprogramming (see packages/active_record's has_many), not for compiling untrusted input.

closure name() ... end

A plain nested def, as above, is built for exactly one job: a detached patch, meant to be handed to define_method/redefine_method and given a real self later, at installation. Called directly instead -- in place, never installed anywhere -- its self/@ivar references don't mean anything: nothing ever supplied a receiver for them. closure name() ... end is the other case: an ordinary closure, called immediately, that also closes over self (and therefore @ivar and self.foo(...)) the same way it already closes over an ordinary outer local:

class Widget
  def initialize(x)
    @x = x
  end
  def helper(n) = n * 2

  def run(value)
    closure inner()
      self.helper(value) + @x
    end
    inner()
  end
end

Widget.new(10).run(5)  # => 20

Legal only where self already exists -- inside an instance method or a class-owned def self.x -- a closure declared at top level, or inside a plain top-level def, is a compile error rather than something that silently compiles into nonsense. self.foo(...) inside one dispatches correctly whether the captured self is an instance or (inside a def self.x, directly -- not nested any deeper, the same depth-1 limit redefine_method's own patch-factory idiom already has) a class value.

closure's value is an ordinary Callable like any other capturing closure -- which means it still can't cross a Thread.new boundary (Thread.new's own capture-free requirement, see "Threads"), and it still can't be handed to define_method/redefine_method (both require a capture-free callable too) -- closure and plain nested def solve two different problems and aren't interchangeable.

One real, separate limitation closure inherits from plain nested def, not something this feature fixes: redeclaring either one a second time inside the same loop body raises a runtime TypeError at the second declaration.

Reopening

A second class Name ... end (or module Name ... end) for a name that already exists adds to it -- new methods, new @ivar-backed fields, new nested classes -- rather than erroring, whether the second declaration is later in the same file or (the more common reason to want this) in a separate file pulled in by require:

class Widget
  def initialize(name)
    @name = name
  end
  def name() = @name
end

class Widget
  def rename(new_name)
    @name = new_name
  end
end

w = Widget.new("ada")
w.rename("grace")
w.name()  # => "grace"

Redefining a method that already exists is still a compile error (duplicate or excessive method definition) -- reopening only ever adds, it doesn't loosen this. A reopen's own < Super clause (if any) is checked against whatever superclass the class already has (from an earlier declaration this compile), not re-applied: omit it to leave the existing superclass alone, restate the same one for a harmless no-op, or name a different one to get a compile error (superclass mismatch for reopened class) rather than silently changing what the class inherits from underneath already-written code. Interfaces don't support reopening -- a second interface Name is still a hard error.

self inside def self.x

A def self.x method declared directly inside a class (not a module -- see below) can use self, and self.method_name(...) dispatches virtually: it looks method_name up against self's actual class, walking that class's own superclass chain, rather than resolving to whatever's lexically visible where the calling method happens to be defined. This is what lets one method shared on a base class reach each subclass's own override:

class Model
  def self.table_name()
    "model_default"
  end
  def self.describe()
    self.table_name()
  end
end

class Author < Model
  def self.table_name()
    "authors"
  end
end

Author.describe()  # => "authors" -- describe is inherited from Model,
                    # but self.table_name() still reaches Author's own
Model.describe()    # => "model_default"

self also works as an ordinary value with no following call (it holds the class itself, comparable with ==, printing as its name, and usable anywhere a value is expected; self.name() and self.to_s() return the name as a String unless the class defines its own singleton of that name) -- but a Class value has no general-purpose literal syntax of its own. self inside a class-owned singleton method is one way to obtain one; a bare class name as a case/when pattern (when Dog, core syntax) is the other. Neither is a general expression -- there is still no way to store a class in a variable outside those two positions, pass one as an ordinary argument, or name one dynamically by a computed string (see docs/internal/design.md's DIAMOND_VALUE_CLASS section, and docs/roadmap.md's "Explicitly deferred" section for why that stays out of scope).

Inside a class-owned singleton method, self.foo(...) and a bare foo(...) mean the same thing: both dispatch on self's actual class at run time, so a subclass's override wins and an inherited singleton method is found:

class Greeter
  def self.greet(name) = prefix() + name
  def self.prefix() = "hello "
end
class Shouter < Greeter
  def self.prefix() = "HEY "
end
Shouter.greet("b")   # => "HEY b"

The bare form needs a singleton method of that name on the class or a superclass (defined above or below the caller) and, like the instance-method form, never overrides a function of the same name: a top-level def foo still wins. A name with no match is the usual undefined function compile error. (Bare sibling calls were once an error here: they used to reach a sibling by accident, and giving self a real value closed that accident. They now resolve deliberately, through self.)

Module namespace singletons (def self.name inside a module block) are unaffected by any of this -- self still isn't accessible there, and ModuleName.name(...) still resolves entirely at compile time. Modules have no superclass chain and nothing to virtually dispatch against.

Class singleton declarations are visible throughout their class body. A method or constant initializer can call a later ClassName.method(...) definition, and singleton methods can call each other recursively. Calls and bare method references support this ordering, including explicit generic arguments. Module functions (module_function and def self.name) also support forward positional calls and references such as Helpers.identity[Int](42) or Helpers.identity[Int]; the final compilation pass validates the discovered signature. Forward calls also accept keyword and spread arguments, including mixed positional/keyword calls and trailing blocks. Defaults, generic inference, and parameter checks work as they do for calls to earlier declarations. Duplicate definitions remain errors.

Bare singleton method references

class AuthorsController
  def self.show(request, context, params)
    # ...
  end
end

handler = AuthorsController.show   # no call -- a Callable[3] value
handler(request, context, params)  # invoked later, elsewhere

ClassName.method/ModuleName.method, with no (...) following, is a Callable value referencing that singleton method -- a small, zero-capture wrapper matching the method's required/optional and variadic arity. Works for a class self. method and a module singleton function alike. Variadic wrappers collect and forward through singleton spread. Generic references accept explicit bindings at the reference site (Tools.identity[Int]); the wrapper bakes concrete bindings into its forwarded call and snapshots enclosing generic bindings when necessary (Tools.identity[T] inside a generic factory). These saved type graphs also survive thread transfers when the reference has no captured value cells. A direct, statically resolved Callable argument may instead provide those bindings contextually. Without either source, a bare unbound generic reference remains a compile error.

Bound instance-method references

receiver.method, with no (...), captures the evaluated receiver once and returns a variadic Callable. Calling it later performs ordinary dynamic method lookup and forwards all positional arguments, so overrides, inheritance, visibility, method_missing, variadic methods, and native receivers behave as they do at a direct call site. Explicit bindings are retained: converter.identity[Int] produces a bound generic-method Callable. Bound method Callables are capturing closures and therefore retain the ordinary capturing-Callable restrictions for Thread.new.

Class variables

class Counter
  def self.reset()
    @@count = 0
  end
  def initialize()
    @@count = @@count + 1
  end
  def self.count()
    @@count
  end
end

Counter.reset()
Counter.new()
Counter.new()
Counter.count()  # => 2

@@cvar is ordinary mutable storage shared by every instance method, def self. method, and initialize call in a class — a plain per-class slot, not a per-instance field. Reading one that's never been assigned gives nil, the same default an instance field gets; there's no separate declaration step. Two different classes' own @@x never collide, even with the same name — each class gets its own slot. @@cvar[index] = value and @ivar[index] = value (indexed assignment into the variable itself, as opposed to reassigning the whole variable) both work: @current[key] = value loads @current's existing Hash/Array into a register and mutates it in place through DIAMOND_OP_INDEX_SET, the same way indexing a plain local already did — there's no extra "write the mutated value back to the ivar/cvar" step needed, since Hash/Array are heap-allocated reference values in the first place.

In a module, @@cvar belongs to the module: its def self. functions and the instance methods it mixes into classes all share the same variables (each module has its own). @@cvar outside any class or module (a bare top-level def) is a compile error — there's no implicit global scope it could fall back to. That's also why a threads: N server (see packages/gremlin/README.md) can't use a class variable to share state across Thread.new-spawned workers: each spawned thread gets its own completely independent copy of every class variable, the same way it gets its own heap and its own everything else (see docs/threads.md) — consistent, not a special case, but worth knowing going in if the goal is one counter shared across all workers rather than one counter per worker.

Method visibility

public, protected, and private change the visibility of following methods in a class or module. They also accept existing method names, such as protected compare, token. Public methods are callable everywhere. Protected methods may use an explicit receiver only while executing an instance method inside the declaring class hierarchy. Private methods remain restricted to the current implicit/self receiver. respond_to? reports public and protected methods, but not private methods.

tap / dup / freeze / frozen? / respond_to? / public_send

class Point
  def initialize(x, y)
    @x = x
    @y = y
  end
  def x() = @x
  def y() = @y
end

p1 = Point.new(1, 2)
p2 = p1.dup()          # a distinct instance, same field values
p1.respond_to?(:x)     # => true
p1.respond_to?(:zoom)  # => false
p1.public_send(:x)     # => 1

[1, 2, 3].tap() do |arr|
  puts(arr.length())   # side effect, doesn't change the chain
end.push(4)             # => [1, 2, 3, 4]

point = Point.new(3, 4)
point.freeze()
point.frozen?()         # => true
point.dup().frozen?()   # => false -- dup never carries freeze over

tap yields the receiver to a block and returns the receiver itself (not the block's own result) — works on any receiver, native or user-defined, primitives included (5.tap() do |x| ... end is valid).

dup returns a shallow copy: a distinct Array/Hash/Instance (same elements/fields, independently mutable afterward), or the same value back unchanged for anything already immutable (Int, String, Symbol, and every other primitive). Only defined for Array, Hash, Instance, and primitives — native resource-backed types (Regexp, Time, File, Socket, ...) don't support it, since "shallow copy" isn't a well-defined operation for those. A class that defines its own dup always wins over this default.

freeze() marks an Array, Hash, or Instance immutable and returns the receiver (chainable); frozen?() reports whether it's marked. Every native mutation those three kinds support -- Array#push/#pop, []= on an Array or Hash (single index or a range), and an instance variable write -- raises a rescuable FrozenError instead of proceeding once the receiver is frozen:

values = [1, 2, 3].freeze()
begin
  values.push(4)
rescue error: FrozenError
  puts(error.message())  # => "frozen object cannot be modified"
end

Every other Array/Hash method d6 has is either already read-only (length, each, [], slice, keys, ...) or -- like delete_at, built from []= and pop in the standard library -- raises the same way by hitting one of those two primitives internally; there's no separate list of "mutating methods" to keep in sync, since d6's own Array/Hash have no in-place !-suffixed methods at all (sort, reverse, uniq, and friends already return a new collection, same as Ruby's own non-! forms). freeze/frozen? mirror dup's own "already immutable" precedent for a primitive or an already-immutable String/Symbol: freeze() is a harmless no-op, frozen?() is always true -- never an "undefined method" error, matching how 5.dup() already works today. Not defined for native resource-backed types (Regexp, Time, File, Socket, ...), the same set dup itself excludes. A class that defines its own freeze/frozen? always wins over this default, exactly like dup.

dup never carries a source's frozen status to the copy -- a dup of a frozen value is itself unfrozen and freely mutable, the same distinction Ruby draws between dup and clone. Freezing is also shallow: freezing a Hash/Array/Instance only marks that one value -- a Hash it merely holds a reference to (in a value, or an ivar) stays exactly as mutable as it was.

deep_freeze() is the transitive version: it freezes the receiver and every Array element, Hash key/value, and Instance field reachable from it, then returns the receiver. It terminates on cyclic graphs (a node whose descendant refers back to it) and does not recurse the C stack, so very deep structures are fine. It still descends into a value that was already shallowly frozen, so x.freeze() followed by x.deep_freeze() freezes what x holds. Nested instances are frozen directly -- a nested instance's own freeze override is not called -- while a class that defines its own deep_freeze always wins for the receiver, like dup. Immutable values and primitives are left alone (no-op), and native resource-backed types are skipped rather than traversed.

respond_to?(name) takes a Symbol and checks whether the receiver's class defines a method by that name — false for a private method, same as Ruby's own default (no include_private second argument yet). On a String, Array, Hash, Int or Float it is true for a native method, a prelude method, or a program's own string_x/array_x/hash_x/integer_x/float_x extension function, and the name may be a Symbol or a String: "abc".respond_to?(:upcase). Other native values (File, Process, a Socket) do not answer it.

Like tap, a class's own method of the same name always takes priority over dup/respond_to?/public_send's own built-in behavior, checked first.

public_send(name, *arguments) invokes the method named by a Symbol or String. It follows ordinary dynamic dispatch for native and user-defined receivers, including inheritance, overrides, variadic methods, and method_missing. Only public targets are callable: private and protected methods are rejected even when public_send itself is called from within the target's class hierarchy. d6 intentionally provides no visibility- bypassing send counterpart. A user class may define its own public_send; that method takes priority over the universal behavior.

method_missing

class Ghost
  def method_missing(name, args)
    "called #{name} with #{args.length()} args"
  end
end

g = Ghost.new()
g.anything()        # => "called anything with 0 args"

def method_missing(name, args) on a class is consulted whenever ordinary instance-method dispatch finds no method by that name anywhere on the receiver's class or its ancestors -- name is the attempted method as a Symbol, args an Array of the call's own arguments (the receiver itself isn't included). A real method of that name always wins first, on any ancestor, so method_missing can never intercept a call to something the class actually defines. Without one, a dispatch miss raises NoMethodError, same as before this feature existed; if method_missing itself doesn't take exactly two required parameters, a miss raises ArgumentError instead. It's found via ordinary inherited lookup, so one defined on a superclass covers every subclass too. Scoped to this one dispatch site only -- not operator overloading, to_s, super, or self.-singleton calls, each of which already has its own fallback.

When the receiver has a method one or two edits from the missing name (a swapped pair of letters counts as one edit), the error names it: undefined method 'brak' for an instance of Dog -- did you mean 'bark'?. Strings, Arrays, Hashes, Ints and Floats get the same hint from their built-in methods and their extension functions (string_*, array_*, ...). Names under three characters get no hint.

Sealed classes

sealed class Shape
end
class Circle < Shape
  def initialize(radius: Int)
    @radius = radius
  end
end
class Square < Shape
  def initialize(side: Int)
    @side = side
  end
end

sealed class Name ... end marks a class an author intends as a closed, fixed set of subclasses -- an algebraic sum type built from ordinary classes rather than an explicit A | B union annotation. It composes normally with an explicit superclass, generics, and interfaces; sealed only ever sets one flag, nothing else about class-header parsing changes. Reopening (see above) is a one-way ratchet -- a class sealed by any one of its declarations stays sealed even if a later reopening omits the keyword, never the other way around.

Two effects, both narrow:

  • Shape.new(...) is a compile error (cannot instantiate a sealed class directly) -- only a subclass can be constructed. This is what makes the second effect below sound: with no possible direct-Shape instance, every runtime value handed to something typed shape: Shape is provably one of its subclasses.
  • A case subject whose plain (non-union) type names a sealed class becomes eligible for exhaustiveness checking over that class's own direct subclasses, the same way an explicit Circle | Square union already is -- see that section for exactly which when forms count as coverage, and for the two conditions (zero or more than 8 direct subclasses) that leave a sealed hierarchy unchecked instead of erroring.

Deliberately not a module/file-boundary enforcement mechanism, unlike Kotlin's or Rust's own sealed/closed types -- and this isn't a smaller version of that feature, it's a different one for a real architectural reason. d6's own compiled source has no per-file or per-module boundary that survives past require expansion at all (the top-level README's own pipeline: source -> require expansion -> lexer -> Pratt compiler splices every required file's text into one flat buffer before lexing begins), and there is no separate/incremental compilation model either -- every program is compiled fresh, as one flat unit, every single run. The classic justification for sealed types elsewhere (a consumer, compiled separately and later, could otherwise add a subclass a library's own exhaustive case never accounted for) simply doesn't apply here: there is no "later, separately compiled" consumer to guard against. Whatever subclasses exist when a program is compiled are, by construction, the complete set for that one compile -- sealed is an explicit, opt-in author promise plus the one restriction needed to make matching just the direct subclasses sound, not a barrier against some external boundary that doesn't exist in this language.

struct declarations

struct Point(x: Int, y: Int)
end

p1 = Point.new(1, 2)
p2 = Point.new(x: 1, y: 2)
p1.x()               # 1
p1 == p2              # true
p1.to_s()             # "Point(x: 1, y: 2)"

struct Name(field: Type, ...) ... end declares an ordinary class (no runtime class synthesis -- see roadmap.md's "Explicitly deferred" section on why that approach was ruled out) with one field per declared member, and generates four methods for it automatically:

  • initialize -- checks each argument against its field's type and assigns it to its matching field, in order. Point.new(1, 2) and the keyword form Point.new(x: 1, y: 2) both work, the same as a hand-written def initialize(x: Int, y: Int).
  • One reader per field (x, y above) -- exactly what attr_reader would generate, including the declared field type.
  • ==(other) -- true only when other is exactly the same class (checked first; comparing against an unrelated type, a different class, or a subclass instance is false, never a raised error) and every field is == to its counterpart.
  • to_s() -- "Point(x: 1, y: 2)": the class name, then each field as name: value.to_s(), comma-separated.

Fields added later are covered too. A plain class that inherits from a struct gets its own generated == over every field when it adds fields (an @tag its initialize assigns, say), and a generated to_s with its own name and every field; so does a struct whose own body assigns fields beyond the declared ones. A hand-written or included ==/to_s, in the class or in any class between it and the struct, is kept instead, and its subclasses inherit it.

Generic structs declare one or two parameters before the field list:

struct Result[T](value: T, error: String | Nil)
end

result = Result.new(42, nil)
explicit = Result[Int].new(value: 42, error: nil)
puts(result.value())
puts(result == explicit)

Generated initializers and readers check fields using the instance's fixed binding. Construction supports inference and explicit arguments just like generic classes. Generated equality compares class identity and field values; it does not compare type arguments separately. Structs generate readers; body methods retain the existing class mutation and freeze behavior. Reopening is rejected.

Generic structs can inherit earlier structs using fixed, forwarded, reordered, mixed, or transformed parent arguments:

struct Cell[T](value: T)
end
struct TaggedCell[T, U](tag: U) < Cell[T]
end

tagged = TaggedCell.new(42, "answer")
puts(tagged.value())
puts(tagged.tag())
puts(tagged == TaggedCell[Int, String].new(42, "answer"))

The generated constructor takes inherited fields first and new fields last. Parent field annotations are specialized into the child's parameter namespace, so TaggedCell infers T from value and U from its own tag field. Inherited readers and methods keep the parent bindings; generated == and to_s include all fields. Repeated parent parameters share one inferred binding; parameters used only by new fields need not appear in the parent arguments. For example, struct TaggedPair[T, U](tag: U) < Pair[T, T] infers the shared T from both inherited fields and infers U independently from tag. A generic child may also extend a nongeneric or fixed struct parent. A nongeneric child can fix a generic parent's arguments, for example struct NamedInt(name: String) < Cell[Int].

A struct composes normally with everything else: .freeze()/ .frozen?() work on an instance like any other (tap / dup / freeze / frozen? / respond_to? / public_send), a struct's own class can appear in a Type | Type union and participate in exhaustiveness checking the same way any other class does, and a field's declared type may refer to the struct's own name (struct Node(value: Int, rest: Node | Nil)) -- the class is registered before its field list is parsed specifically so this resolves.

A struct's own body can supplement the generated methods -- ordinary def/attr/attr_reader/attr_writer/attr_accessor/attr_predicate/ include/private/protected/public/alias_method/delegate lines between the field list and end, exactly as a class body accepts:

module Greetable
  def greet() = "hi, #{self.name_for_greeting()}"
end

struct Point(x: Int, y: Int)
  include Greetable

  def name_for_greeting() = "point"

  def distance_squared_to(other)
    dx = @x - other.x()
    dy = @y - other.y()
    dx * dx + dy * dy
  end
end

A hand-written def == or def to_s replaces the generated one (the generated == compares every field; a struct that should compare by identity of some fields only, or print differently, says so in its body):

struct Point(x: Int, y: Int, label: String)
  def ==(other) = other is Point && @x == other.x() && @y == other.y()
  def to_s() = "(#{@x}, #{@y})"
end

Everything else stays add-only: def initialize, or a def/attr matching a field's own generated reader name, is still a compile error ("duplicate or excessive method definition" / "attribute method is already defined"), since replacing the constructor or a reader would break the field list the rest of the struct relies on.

Deliberately narrow for this first pass:

  • A superclass must be another struct declared earlier: struct Point3D(z: Int) < Point. The parent's fields come first, then the struct's own, and the generated initialize (Point3D.new(1, 2, 3)), == and to_s cover all of them; the parent's readers and methods carry over. A field repeating one of the parent's is a compile error, and a plain class is not accepted as a parent. (An ordinary class can still subclass a struct's class the normal way.)
  • No reopening. A second struct Point(...) for an already-declared name is a compile error, unlike a plain class. Regenerating initialize/==/to_s for a redeclared field list has no obviously correct semantics, so it's simply disallowed rather than guessed at.
  • Field types are required. struct Point(x, y) (no : Type) is a compile error -- this is what lets the generated readers carry a real return type and keeps ==/to_s simple.
  • A field cannot be named initialize, ==, or to_s (it would collide with a generated method).

Operator overloading

class Vector
  def initialize(x, y)
    @x = x
    @y = y
  end
  def +(other)
    Vector.new(@x + other.x(), @y + other.y())
  end
  def negate()
    Vector.new(-@x, -@y)
  end
  def ==(other)
    other is Vector && @x == other.x() && @y == other.y()
  end
  def x() = @x
  def y() = @y
end

Vector.new(1, 2) + Vector.new(3, 4)  # => #<Vector x=4, y=6>
-Vector.new(1, 2)                    # => #<Vector x=-1, y=-2>

A class can define +, -, *, /, %, ==, <, <=, >, >= as ordinary instance methods, and d6's own operator syntax (a + b, a == b, ...) dispatches to them — same mechanism as any other method (inherited, overridable, reachable through super, and satisfies an interface that requires a method of the same name). Unary minus (-x) is a method named negate, not Ruby's -@ spelling — a plain identifier, since - alone already names the binary form and d6 doesn't need new lexer syntax to tell them apart by name (they're already told apart by arity: negate takes no extra arguments, - takes exactly one).

Dispatch is receiver-based only, same as every other method call in d6: a + b checks whether a is an instance whose class defines +. There's no coercion protocol — if a is a plain Int/Float/String and b is an instance, a + b still raises TypeError; the instance has to be on the left. != isn't separately overloadable — it's always the negation of =='s result (run through the same truthiness if/while use, not required to be exactly Bool), matching Ruby's own default. Two instances of a class that doesn't define == still compare by identity, exactly as before this feature existed — defining == only changes behavior for classes that opt in.

<< isn't overloadable (see below for why). []/[]= and <=> are — see their own sections below.

[]/[]= indexing

class Box
  def initialize()
    @data = {}
  end
  def [](key) = @data[key]
  def []=(key, value)
    @data[key] = value
  end
end

box = Box.new()
box["a"] = 1
box["a"]  # => 1

x[i] dispatches to x's own [] method (one required parameter, the index); x[i] = v dispatches to []= (two required parameters, index then value) — same mechanism as every other overloadable operator above, so inheritance/super/interface-satisfaction all work the same way. x[i] += v and chained x[a][b] = v need no special support: each is already just ordinary reads ([]) and one final write ([]=) under the hood, and both already dispatch through the receiver correctly. As with every other operator here, there's no coercion — whatever sits between the brackets (an Int, a Range, anything) is handed to []/[]= verbatim, with none of Array's own Range-based slicing behavior applied. x[i] = v's own value as an expression is always v itself, regardless of what []= returns — matching Ruby's own []= semantics, and matching how x[i] = v already worked before this feature existed for Array/Hash. A class that defines neither still raises TypeError on x[i]/x[i] = v, exactly as before this feature existed.

This mechanism (a user-defined class's own instance methods) is the only way user code opts into operator support. Time is the one native, non-Instance type with real +/-/comparison support — implemented directly in the VM's own arithmetic/comparison opcodes, not through this dispatch, since a native type has no instance methods for it to reach. << is native the same way, on two types: Int << Int is a bitwise left shift (the amount must be 0..1048576, else RangeError, and a result past 64 bits promotes to an arbitrary-precision Int like any other overflow); Array << value pushes value and returns the array itself, the same as Array#push, letting pushes chain (arr << 1 << 2 << 3). << binds tighter than comparisons but looser than +/- (1 + 2 << 3 is (1 + 2) << 3, matching Ruby). Any other left operand (a String, an Instance, ...) is a TypeError — no other native type (Hash, File, SQLite3, ...) gets operators this way; each would need its own dedicated VM-level support, same as Time and << did.

>>/&/|/^ are Int-only bitwise operators sharing <<'s own scope cut: not user-overloadable, no quickening. They work on arbitrary-precision Ints too, with Ruby's infinite two's-complement semantics for negative operands (-1 & x is x). >> is arithmetic (sign-extending, matching Ruby's Integer#>> — -8 >> 1 is -4, and a shift past the width gives 0, or -1 for a negative value); a negative shift amount raises RangeError. &/|/^ are ordinary bitwise and/or/xor; either operand not an Int is TypeError. All four share <<'s own precedence tier (no separate tier per bitwise op). &/| also have unrelated meanings elsewhere in the grammar (&block parameter- forwarding, do |x| ... end block params, TypeError | NoMethodError rescue-clause union types) — none of those go through general expression parsing, so there's no ambiguity with the operator form.

<=> and Comparable

class Box
  include Comparable
  def initialize(size)
    @size = size
  end
  def <=>(other) = @size - other.size()
  def size() = @size
end

Box.new(1) < Box.new(2)                    # => true
Box.new(5).between?(Box.new(1), Box.new(10))  # => true
Box.new(15).clamp(Box.new(1), Box.new(10)).size()  # => 10

a <=> b returns -1/0/1 (an Int), or Nil for a pair with no defined ordering — never a raised error on its own, unlike every other comparison operator. Built in for Int/Float (including arbitrary-precision Ints and mixed Int/Float operands; NaN on either side is Nil, matching Float::NAN <=> 1 in Ruby), for String (byte-lexicographic, the same ordering as C's strcmp/ Ruby's own String#<=> — not UTF-8/locale-aware, matching String being a raw byte buffer everywhere else in the language), and for any Instance whose class defines its own <=> method — anything else (Time, an Instance with no <=>, a String/Int pair with mismatched types, ...) is Nil too, not TypeError. String also has native </<=/>/>= built directly on the same byte comparison (not derived through Comparable, since String is a native type with no instance methods for include to reach) — ["banana", "apple", "cherry"].sort_by() do |s| s end works directly, with no need to extract a sortable key first.

include Comparable derives <, <=, >, >=, ==, between?, and clamp from that one <=> method — the same "several methods derived from one" relationship Enumerable has with each. A genuinely incomparable pair still surfaces as an error eventually ((self <=> other) < 0 becomes nil < 0), just one level removed from <=> itself.

Modules

A program may declare up to 1,024 distinct modules (and, separately, 1,024 interfaces), including the prelude and all required cuts. Reopening a module does not consume another slot. A class or module may directly include up to 255 distinct modules.

module Name ... end declares a module; include Name copies its method descriptors into the receiving class (mixin-style composition, not inheritance). A class's (or module's) own methods always beat the ones it includes, whichever comes first in the source: include skips a module method the receiver already defines itself, so def == written before include Comparable still wins over Comparable's derived ==. Two included modules that define the same method resolve to the later include. (A struct's generated ==/to_s are defaults, not own methods: an included Comparable's derived == still replaces the generated one, while a hand-written def == in the struct body wins over both.) super from a method reaches the superclass chain, not an included module. def self.name inside a module declares a namespace singleton function rather than an instance method: Name.name(...), or a bare name(...) from inside the module itself -- never a bare call from outside it, so module Geometry ... def self.sqrt(x) doesn't hijack the global sqrt. Like a class (see "Reopening" above), a module can be reopened -- a second module Name adds more methods/nested classes to the same module instead of erroring, the usual way to split a module's classes across several files while keeping one require-able entry point.

Edit this page on GitHub