Classes
The Blueprint That Does Things
In Structs we built custom boxes with labeled compartments -- like a LEGO car with slots for color, speed, and name. But what if you wanted the car to drive? Or honk? A struct can hold data, but it can't do anything with it.
A class is like a struct that has learned some tricks. It has fields (the labeled compartments) and methods (things it knows how to do). Think of a class as a blueprint that includes both the parts list and the instruction manual.
Defining a Class
Use the class keyword followed by a name, then list the fields and
methods inside curly braces:
class Dog {
name,
age,
fn bark(self) {
print("Woof! My name is " + self.name)
}
fn birthday(self) {
self.age = self.age + 1
}
}
The fields come first (comma-separated, just like struct fields),
and then the methods (defined with fn, just like regular functions).
The self Parameter
Every method must have self as its first parameter. This is
how the method knows which instance it belongs to.
When you call myDog.bark(), Pebble automatically passes myDog as
self. You don't type self in the call -- Pebble handles it for
you. Inside the method, self.name means "the name field of
whichever dog called this method."
Think of self as a mirror. When the dog looks in the mirror, it
sees itself -- its own name, its own age, its own data.
Creating Instances
You create a class instance the same way you create a struct -- call the class name like a function, passing one value for each field:
The arguments are positional -- the first value fills the first field, the second value fills the second field, and so on. If you pass the wrong number of arguments, Pebble catches the mistake before your program runs.
Calling Methods
Use a dot (.) followed by the method name and parentheses:
Even though both dogs use the same bark method, each one sees its
own data through self. Rex's self.name is "Rex", while Luna's
self.name is "Luna".
Methods That Change Things
Methods can modify the instance's fields using self.field = value:
The birthday method changed Rex's age, but Luna's age stays the
same. Each instance is independent.
Methods with Extra Parameters
Methods can take additional parameters beyond self:
class Calculator {
value,
fn add(self, n) {
self.value = self.value + n
}
fn multiply(self, n) {
self.value = self.value * n
}
}
let calc = Calculator(0)
calc.add(10)
calc.multiply(3)
print(calc.value) # prints: 30
When calling calc.add(10), Pebble passes calc as self and 10
as n. You only write the extra arguments in the call.
Methods That Return Values
Methods can return values just like regular functions:
class Rectangle {
width,
height,
fn area(self) -> Int {
return self.width * self.height
}
fn is_square(self) -> Bool {
return self.width == self.height
}
}
let r = Rectangle(4, 6)
print(r.area()) # prints: 24
print(r.is_square()) # prints: false
Type Annotations on Methods
Method parameters and return types can have type annotations, just like regular functions:
class Greeter {
name: String,
fn greet(self, greeting: String) -> String {
return greeting + ", " + self.name + "!"
}
}
let g = Greeter("Alice")
print(g.greet("Hello")) # prints: Hello, Alice!
Fields can have type annotations too -- they work the same way as struct field annotations.
Structs, Classes, and Enums
Pebble has three tools for custom types, each serving a different purpose:
| Feature | Struct | Class | Enum |
|---|---|---|---|
| Fields | Yes | Yes | No |
| Methods | No | Yes | No |
| Operator overloading | No | Yes | No |
| Use when... | Group related data | Data that needs to do things | Fixed set of named options |
Think of it this way:
- A struct is a form -- it has blanks to fill in, but it doesn't do anything on its own. Like an address card with name, street, and city.
- A class is a robot -- it has data and it knows how to act on that data. Like a calculator that holds a number and can add, subtract, or multiply.
- An enum is a menu -- a fixed list of choices, like an ice-cream shop with Chocolate, Vanilla, and Strawberry.
Both structs and classes create instances the same way (Name(args)),
and both support field access (inst.field) and field mutation
(inst.field = value). Enums use dot syntax instead: Color.Red.
See Enums for details.
Error Cases
Pebble catches class-related mistakes early:
# Method missing self parameter
class Bad {
fn do_thing() { } # Error: first parameter must be 'self'
}
# Wrong number of constructor arguments
let d = Dog("Rex") # Error: expects 2 arguments, got 1
# Calling a method that doesn't exist
d.fly() # Error: Class 'Dog' has no method 'fly'
# Calling a method on a non-class value
let x = 42
x.bark() # Error: not a struct
# Duplicate field names
class Bad { x, x } # Error: Duplicate field 'x'
# Duplicate method names
class Bad {
fn go(self) { }
fn go(self) { } # Error: Duplicate method 'go'
}
Practical Examples
Counter
class Counter {
count,
fn increment(self) {
self.count = self.count + 1
}
fn reset(self) {
self.count = 0
}
fn get(self) -> Int {
return self.count
}
}
let c = Counter(0)
c.increment()
c.increment()
c.increment()
print(c.get()) # prints: 3
c.reset()
print(c.get()) # prints: 0
Bank Account
class Account {
owner,
balance,
fn deposit(self, amount) {
self.balance = self.balance + amount
}
fn withdraw(self, amount) {
self.balance = self.balance - amount
}
fn summary(self) -> String {
return self.owner + ": $" + str(self.balance)
}
}
let acc = Account("Alice", 100)
acc.deposit(50)
acc.withdraw(30)
print(acc.summary()) # prints: Alice: $120
Game Character
class Player {
name,
hp,
score,
fn take_damage(self, amount) {
self.hp = self.hp - amount
}
fn earn_points(self, points) {
self.score = self.score + points
}
fn is_alive(self) -> Bool {
return self.hp > 0
}
}
let hero = Player("Knight", 100, 0)
hero.earn_points(50)
hero.take_damage(30)
print(hero.hp) # prints: 70
print(hero.is_alive()) # prints: true
How It Works Under the Hood
Defining a Class
When the compiler sees a class definition, it does two things:
- Stores field metadata -- same as structs. The class name and ordered field list go into the struct registry so the VM knows how to construct instances.
- Compiles each method as a function -- with a special mangled
name. The method
barkin classDogbecomes a function called"Dog.bark". This lets Pebble reuse its entire existing function machinery -- no special "method frame" needed.
Constructing an Instance
Class instances are created exactly like struct instances. When the
VM sees CALL "Dog", it checks the struct registry, pops the field
values, and builds a StructInstance. No new opcode needed -- classes
and structs share the same construction path.
Calling a Method
When you write d.bark(), the compiler emits a special instruction:
The operand "bark:0" encodes two things: the method name (bark)
and the number of extra arguments (0).
The VM then:
- Pops the arguments (if any) from the stack
- Pops the target instance (
d) - Looks up
"Dog.bark"in the function registry - Creates a new frame with
selfbound tod - Executes the method body
Here's the flow as a diagram:
sequenceDiagram
participant Code as Your Code
participant Compiler
participant VM
participant Stack
Code->>Compiler: d.bark()
Compiler->>VM: LOAD_NAME "d"
VM->>Stack: push Dog instance
Compiler->>VM: CALL_INSTANCE_METHOD "bark:0"
VM->>Stack: pop 0 args
VM->>Stack: pop target (Dog instance)
VM->>VM: look up "Dog.bark" function
VM->>VM: bind self = target
VM->>VM: push new frame, execute body
Why Not Reuse CALL_METHOD?
Pebble already has a CALL_METHOD opcode for built-in methods like
push() on lists or upper() on strings. Those built-in methods
have fixed arities and use a special padding scheme on the stack.
User-defined class methods can have any number of parameters, so
Pebble uses a separate CALL_INSTANCE_METHOD opcode with a simpler
design. This keeps both paths clean and avoids mixing concerns.
What's Next?
Now that you know how classes work, the next step is Inheritance -- where a class can build on top of another class, inheriting its fields and methods. It's like creating a "Dog" that automatically gets everything an "Animal" already has.
See Also
- Closures as Classes -- discover how closures can build the same "private state + methods" pattern, and why classes are syntactic sugar that makes it cleaner.
Summary
| Syntax | What it does |
|---|---|
class Name { fields, fn method(self) { } } |
Define a class with fields and methods |
Name(a, b) |
Create an instance (same as structs) |
inst.method() |
Call a method on an instance |
self.field |
Read a field inside a method |
self.field = expr |
Write a field inside a method |
CALL_INSTANCE_METHOD "name:n" |
VM instruction: call a class method with n args |