Classes and Traits
A class represents a concept from your program's domain. It groups that concept's data (its fields) with its behavior (its methods) and encapsulates the data to protect the class's invariants, the rules that must always hold.
Each instance of a class, called an object, stores values for those fields.
A trait is behavior that many classes share, written once and implemented by each of them.
Classes and objects are primitives in Luwu, like numbers and strings. type() returns "class" for a class and "object" for an object.
From metatables to classes
In Luau, objects are usually tables with a metatable. Each one needs some setup code:
const Account = {}
Account.__index = Account
function Account.new(owner: string)
return setmetatable({ owner = owner, balance = 0 }, Account)
end
export type Account = typeof(Account.new(...))
function Account.deposit(self: Account, amount: number)
self.balance += amount
end
const account = Account.new("Ada")
account:deposit(50)
print(account.balance)That's well-typed Luau. Its type comes from typeof(Account.new(...)), and each method takes self: Account by hand.
In Luwu, the same thing is a class:
class Account(owner: string)
balance = 0
function deposit(self, amount: number)
self.balance += amount
end
end
const account = Account("Ada")
account:deposit(50)
print(account.balance)Type checking
Metatables are hard for the type checker to follow, and types and autocomplete often break on them. With a class, the checker knows every field and method of every object, and a misspelled field is a type error instead of a silent nil.
Classes and traits are nominally typed: what matters is the name, not the shape. Two classes with the same fields are still different types: neither can be passed where the other is expected. A trait's name is a type too, and an object of any class that implements the trait fits it.
Metatables still work. Luwu types tables with metatables structurally like Luau and checks them better through improvements like type references and method calls on union types.
Performance
Classes are also faster than metatable objects. Objects take less memory than tables, and reading or writing a field skips the key lookup a table needs. At --!optimize 2, Luwu can also copy small methods straight into the code that calls them.
Inheritance through traits
Upstream Luau is adding classical inheritance through open class and extends. Luwu takes the useful parts of inheritance and calls them traits. A trait gives classes shared methods with default implementations they can override, lists members each class is expected to provide, and can require other traits.
Luwu leaves out two parts of classical inheritance. Building an object only ever runs the constructor of its class. There's no is-a identity either: an object belongs to exactly one class, and class.isinstance checks that class alone. class.implements answers the separate question of whether an object has some trait's behavior. Traits are covered further down.
Classes
Your first class
The parameters after the class name make up its primary constructor and become fields of the object.
Functions that take self are methods. You call them with :.
class Dog(name: string, age: number)
function bark(self)
print(`{self.name} barked!`)
end
function birthday(self)
self.age += 1
end
end
const oreo = Dog("Oreo", 3)
oreo:bark()
oreo:birthday()
print(oreo.age)Calling a class like a function builds a new object. Passing it the wrong number or type of arguments is a type error.
Plain old data (POD) classes
Some classes only hold data, like a named table or a struct. Luwu calls these plain old data (POD) classes. Give each field a default value and leave out the constructor.
Then build objects from a table with the POD constructor. Fields you leave out get their defaults.
class Settings
volume = 0.8
fullscreen = false
player_name = "Player"
end
const defaults = Settings()
const custom = Settings { fullscreen = true, player_name = "Ada" }
print(defaults.volume, custom.fullscreen, custom.player_name)Default values are created fresh for every object. Two objects never share a default table by accident.
class Inventory
items: { string } = {}
end
const a = Inventory()
const b = Inventory()
table.insert(a.items, "sword")
print(#a.items, #b.items)Static functions
A function without self belongs to the class itself. Call it with a dot.
Static functions are a good place for alternative ways to build an object.
class Temperature(celsius: number)
function from_fahrenheit(fahrenheit: number): Temperature
return Temperature((fahrenheit - 32) * 5 / 9)
end
function describe(self): string
return `{self.celsius}°C`
end
end
print(Temperature.from_fahrenheit(212):describe())Computed fields
A field's default can use the constructor's parameters. That's how you clean up or derive data on the way in.
class Username(raw: string)
text = string.lower((string.gsub(raw, "%s+", "_")))
end
const name = Username("Ada Lovelace")
print(name.text, name.raw)The parameter stays a field too: name.raw is still there.
Constructors with __init
Add an __init function when building an object needs custom logic. Luwu creates the object, then calls __init with it and your arguments. Reach for it when:
- the constructor takes parameters that shouldn't become fields
- fields are computed from the arguments over several steps
- construction calls other functions or validates its input
- the constructor needs to yield, like waiting on a file or a network request
This point is built from text like "3,4". It keeps x and y, but not the text:
class Point
x: number
y: number
function __init(self, text: string)
const x, y = string.match(text, "^(%-?%d+),(%-?%d+)$")
self.x = tonumber(x) or 0
self.y = tonumber(y) or 0
end
end
const point = Point("3,4")
print(point.x, point.y)A class has either a primary constructor or __init, not both.
Public, private and const
Mark a member private to keep it inside the class. Code outside the class can't read or change it: trying is a type error and an error at runtime.
Mark a field const when it should never change after the object is built.
Once one member has public or private, every member needs one.
class BankAccount(
public const owner: string,
private balance: number = 0
)
public function deposit(self, amount: number)
if amount > 0 then
self.balance += amount
end
end
public function get_balance(self): number
return self.balance
end
end
const account = BankAccount("Ada")
account:deposit(50)
account:deposit(-10)
print(account.owner, account:get_balance())Because only the class's methods can change the balance, every deposit goes through the same check.
Protecting invariants
Some data is only valid when certain properties always hold: health stays between 0 and its maximum, an age is never negative, a position is never NaN. Those properties are the class's invariants, and encapsulation is how a class protects them. When the fields are private and every change goes through the class's methods, no code outside it can leave the data in an invalid state.
This Health class represents a player's health points. Its constructor and current are private, every change goes through clamped, and its metamethods let you use +, -, == and < on it directly. Printing it draws a healthbar:
const colors = require("@std/io/colors")
const EMPTY = vector.create(243, 139, 168)
const FULL = vector.create(166, 227, 161)
class Health private (
private current: number,
public const max_health: number
)
public function full(max_health: number): Health
return Health(max_health, max_health)
end
private function clamped(current: number, max_health: number): Health
if math.isnan(current) then
error("health can't be NaN", 3)
end
return Health(math.clamp(current, 0, max_health), max_health)
end
public function __add(self, amount: number): Health
return Health.clamped(self.current + amount, self.max_health)
end
public function __sub(self, amount: number): Health
return Health.clamped(self.current - amount, self.max_health)
end
public function __eq(self, other: Health): boolean
return self.current == other.current
end
public function __lt(self, other: Health): boolean
return self.current < other.current
end
public function __le(self, other: Health): boolean
return self.current <= other.current
end
public function __tostring(self): string
const ratio = self.current / self.max_health
const filled = math.round(ratio * 20)
const bar = colors.rgb(vector.lerp(EMPTY, FULL, ratio), string.rep("█", filled))
.. colors.style.dim(string.rep("░", 20 - filled))
return `{bar} {self.current}/{self.max_health}`
end
end
local hp = Health.full(100)
print(hp)
hp -= 65
print(hp)
hp += 500
print(hp, hp == Health.full(100))
hp -= 90
print(hp, hp < Health.full(20))The healthbar's colors come from seal's @std/io/colors library. Save the example as health.luwu and run it with seal:
seal ./health.luwu████████████████████ 100/100 ███████░░░░░░░░░░░░░ 35/100 ████████████████████ 100/100 true ██░░░░░░░░░░░░░░░░░░ 10/100 true
Overhealing by 500 stops at 100/100 because every result passes through math.clamp. From outside the class, hp.current = -20 is a type error and a runtime error, and Health(-5, 100) fails because the constructor is private.
Operators on objects
Metamethods let your objects work with operators like + and ==. Name a method after the metamethod.
A floating point number can't hold most fractions exactly (0.1 + 0.2 is 0.30000000000000004). Money keeps whole units and cents in separate fields instead. Its constructor and fields are private, and every amount comes from Money.usd, Money.eur or Money.gbp. Those reject negative amounts and fractions of a cent before an object ever exists.
type Currency = "USD" | "EUR" | "GBP"
class Money private (
private const units: number,
private const cents: number,
public const currency: Currency
)
public function usd(units: number, cents: number): Money
return Money.of(units, cents, "USD")
end
public function eur(units: number, cents: number): Money
return Money.of(units, cents, "EUR")
end
public function gbp(units: number, cents: number): Money
return Money.of(units, cents, "GBP")
end
private function of(units: number, cents: number, currency: Currency): Money
if
units % 1 ~= 0
or cents % 1 ~= 0
or units < 0
or cents < 0
or cents > 99
then
error("expected whole units and 0 to 99 cents", 3)
end
return Money(units, cents, currency)
end
private function check_currency(self, other: Money)
if self.currency ~= other.currency then
error(`can't mix {self.currency} and {other.currency}`, 3)
end
end
public function __add(self, other: Money): Money
self:check_currency(other)
const cents = self.cents + other.cents
const units = self.units + other.units + cents // 100
return Money(units, cents % 100, self.currency)
end
public function __eq(self, other: Money): boolean
return self.units == other.units
and self.cents == other.cents
and self.currency == other.currency
end
public function __lt(self, other: Money): boolean
self:check_currency(other)
if self.units ~= other.units then
return self.units < other.units
end
return self.cents < other.cents
end
public function __tostring(self): string
return string.format("%d.%02d %s", self.units, self.cents, self.currency)
end
end
const total = Money.usd(19, 99) + Money.usd(5, 1)
print(`{total}`)
print(total == Money.usd(25, 0), total < Money.usd(30, 0))Checking what an object is
class.isinstance tells you whether a value is an object of a class.
Inside the check, Luwu knows the value's type.
class Circle(radius: number) end
class Square(side: number) end
const function area(shape: Circle | Square): number
if class.isinstance(shape, Circle) then
return math.pi * shape.radius ^ 2
end
return shape.side ^ 2
end
print(area(Circle(1)), area(Square(3)))
print(class.name(Square(3)))Traits
Traits are how classes inherit behavior in Luwu. A trait lists what it expects from a class and what it gives the class in return. A class can implement as many traits as it needs.
Sharing behavior
Here, Lootable gives any class a working drop method. It only asks the class for a loot array.
trait Lootable
expect loot: { string }
function drop(self): { string }
const dropped = self.loot
self.loot = {}
return dropped
end
end
class Chest(loot: { string }) implements Lootable end
class Slime(hp: number, loot: { string }) implements Lootable end
const chest = Chest({ "gold", "map" })
const slime = Slime(5, { "goo" })
print(#chest:drop(), #chest:drop(), slime:drop()[1])Both classes get the trait's methods without writing them again.
Expected members
expect lists what an implementing class must provide: fields, functions or both.
Leaving one out is a type error and an error when the class is created.
trait Describable
expect const name: string
expect function details(self): string
function describe(self): string
return `{self.name}: {self:details()}`
end
end
class Sword implements Describable
const name = "Iron sword"
damage = 7
function details(self): string
return `deals {self.damage} damage`
end
end
print(Sword():describe())Overriding a trait's function
A class can replace a trait's function and still reach the trait's version with Trait.function(self).
trait Greeting
function greet(self): string
return "Hello!"
end
end
class Pirate implements Greeting
function greet(self): string
return Greeting.greet(self) .. " Arr!"
end
end
print(Pirate():greet())The override must take the same arguments the trait's version does. Extra arguments must be optional.
Traits that need other traits
A trait can needs another trait. Implementing it implements both.
trait Named
expect const name: string
end
trait Speaker needs Named
function introduce(self): string
return `I'm {self.name}.`
end
end
class Robot implements Speaker
const name = "R2"
end
print(Robot():introduce())Final members
Mark a trait's member final when classes must not replace it.
A final function without self is a static function on the trait. It's a good place for helpers that belong with the trait.
trait Shape
expect function area(self): number
final function total_area(shapes: { Shape }): number
local total = 0
for _, shape in shapes do
total += shape:area()
end
return total
end
end
class Rectangle(width: number, height: number) implements Shape
function area(self): number
return self.width * self.height
end
end
class Circle(radius: number) implements Shape
function area(self): number
return math.pi * self.radius ^ 2
end
end
print(Shape.total_area({ Rectangle(2, 3), Circle(1) }))Traits with parameters
A trait can take parameters, like a class's constructor. The class passes them when it implements the trait.
trait Stackable(max_stack: number = 64)
count = 1
function add(self, amount: number): number
const room = self.max_stack - self.count
const added = math.min(room, amount)
self.count += added
return amount - added
end
end
class Arrow implements Stackable end
class Potion implements Stackable(8) end
print(Arrow():add(100), Potion():add(100))Working with any implementer
A trait is also a type. { Shape } is an array of anything that implements Shape.
Trait is any object whose class implements Trait, class<Trait> is the type of the class value of any class that implements Trait, and trait<Trait> is the type of the trait value itself. Here's an example of all three:
trait Animal -- Animal is trait<Animal>
expect species: string
end
class Cat( -- Cat is class<Animal>
name: string,
age: number
) implements Animal
species = "Felis catus"
end
const nyla = Cat("Nyla", 14) -- nyla is Animalclass.isinstance asks whether a value is an object of one exact class, while class.implements asks whether the value's class implements a trait. Inside either check Luwu narrows the value's type. Once describe knows it has an Animal, reading thing.species type checks:
trait Animal
expect species: string
end
class Cat(name: string) implements Animal
species = "Felis catus"
end
class Dog(name: string) implements Animal
species = "Canis familiaris"
end
class Rock() end
const function describe(thing: unknown): string
if class.implements(thing, Animal) then
return `an animal of species {thing.species}`
elseif class.isinstance(thing, Rock) then
return "a rock"
end
return "something else"
end
print(describe(Cat("Nyla")))
print(describe(Dog("Rex")))
print(describe(Rock()))Written with only class.isinstance, describe would need a check for Cat, another for Dog and one more for every animal class anyone adds later. A single class.implements check covers them all. class.isinstance only takes classes, and when it's given a trait luwu-analyze reports Expected this to be a class, got trait 'Animal'.
Traits as factories
Sometimes code has to build an object without knowing its class. A parser that reads HTML back into objects only knows the tags it found, and apps add components the parser has never heard of. A trait can build those objects. Give the trait a __create function and calling the trait like a class runs it to pick a concrete class and return a new object of that class.
This Component trait holds a set of class<Component> values. Each class is keyed by its identity, and two classes that happen to share a name stay separate entries. Its __implemented function runs once when each class that implements the trait is defined and adds that class to the set. Component("button") then builds a Button:
const components: { [class<Component>]: true? } = {}
trait Component
expect function __init(self)
expect function tag(): string
expect function render(self): string
function __create(tag: string): Component
for cls in components do
if cls.tag() == tag then
return cls()
end
end
return error(`no component renders <{tag}>`, 2)
end
final function __implemented(cls: class<Component>)
components[cls] = true
end
end
class Button() implements Component
function tag(): string
return "button"
end
function render(self): string
return "<button>Save</button>"
end
end
class Spinner() implements Component
function tag(): string
return "progress"
end
function render(self): string
return "<progress></progress>"
end
end
-- tags a parser found in the page
for _, tag in { "progress", "button" } do
print(Component(tag):render())
end__implemented is always final and takes the class rather than self. It also runs for classes that implement the trait through another trait's needs. It runs once per class even when several traits lead to it, and the hooks of the traits a trait needs run first. Traits hold no state. The registry is a table in the module beside it.
Only traits that define __create can be called. Calling the Animal trait from earlier raises trait 'Animal' can't be called: it has no '__create'.
Things to know
- Classes and traits are defined at the top level of a file.
- Objects have exactly the fields their class lists. Reading a missing field is an error.
- Objects compare by identity unless the class defines
__eq. - A class's name is also a type: an object of that class.