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-Shapeinstance, every runtime value handed to something typedshape: Shapeis provably one of its subclasses.- A
casesubject 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 explicitCircle | Squareunion already is -- see that section for exactly whichwhenforms 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 formPoint.new(x: 1, y: 2)both work, the same as a hand-writtendef initialize(x: Int, y: Int).- One reader per field (
x,yabove) -- exactly whatattr_readerwould generate, including the declared field type. ==(other)--trueonly whenotheris exactly the same class (checked first; comparing against an unrelated type, a different class, or a subclass instance isfalse, never a raised error) and every field is==to its counterpart.to_s()--"Point(x: 1, y: 2)": the class name, then each field asname: 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 generatedinitialize(Point3D.new(1, 2, 3)),==andto_scover 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 ordinaryclasscan 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 plainclass. Regeneratinginitialize/==/to_sfor 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_ssimple. - A field cannot be named
initialize,==, orto_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.