rlox: A Rust Implementation of “Crafting Interpreters” – Classes
This post covers Chapter 12 of Crafting Interpreters: Classes. The following new syntax elements have been implemented: Stmt::Class
, Expr::Get
, Expr::Set
, and Expr::This
. Lox now supports class
, this
, and init
. While implementing this chapter, I encountered two stack overflow bugs and several cases where author-provided test scripts produced incorrect results. This post discusses those issues in detail.
🦀 Index of the Complete Series.
![]() |
---|
rlox: A Rust Implementation of “Crafting Interpreters” – Classes |
🚀 Note: You can download the code for this post from GitHub using:
git clone -b v0.6.0 https://github.com/behai-nguyen/rlox.git
💥 The interactive mode is still available. However, valid expressions—such as
((4.5 / 2) * 2) == 4.50;
—currently produce no output.
I’m not sure when interactive mode will be fully restored, and it’s not a current priority.
For now, Lox scripts can be executed via the CLI application. For example:
cargo run --release ./tests/data/constructor/call_init_explicitly.lox
Content of call_init_explicitly.lox:
class Foo {
init(arg) {
print "Foo.init(" + arg + ")";
this.field = "init";
}
}
var foo = Foo("one"); // expect: Foo.init(one)
foo.field = "field";
var foo2 = foo.init("two"); // expect: Foo.init(two)
print foo2; // expect: Foo instance
// Make sure init() doesn't create a fresh instance.
print foo.field; // expect: init
This Lox script is an author provided one, it can be found
here.
The output of this Lox script should be as noted: Foo.init(one)
,
Foo.init(two)
, Foo instance
, and init
.
For more details on the Lox language, refer to
this section of the
README.md
.
Legend: ★ = updated, ☆ = new.
💥 Files not modified are omitted for brevity.
.
├── docs
│ └── RLoxGuide.md ★
├── README.md ★
├── src
│ ├── environment.rs ★
│ ├── interpreter.rs ★
│ ├── lib.rs ★
│ ├── lox_callable.rs ★
│ ├── lox_class.rs ☆
│ ├── lox_clock.rs ★
│ ├── lox_error_helper.rs ★
│ ├── lox_error.rs ★
│ ├── lox_function.rs ★
│ ├── lox_instance.rs ☆
│ ├── lox_return.rs ★
│ ├── main.rs ★
│ ├── parser.rs ★
│ ├── resolver.rs ★
│ └── value.rs ★ ➜ formerly data_type.rs
└── tests
├── data/ ☆ ➜ a lot more were added
├── README.md ★
├── test_classes.rs ☆
├── test_interpreter.rs ★
└── test_parser.rs ★
The code for Chapter 12 initially appeared straightforward. However, testing it against the relevant scripts from the author’s Crafting Interpreters test suite revealed some existing flaws in my earlier implementation, as well as mistakes introduced during development. Working through these issues deepened my understanding of Rust, particularly around ownership, pointer identity, and trait object behavior. This post explores those challenges in detail.
❶ #[derive(Debug)]
Can Potentially Cause Stack Overflow
Consider the following Chapter 11 code:
-
pub struct LoxFunction
— TheLoxFunction
struct implementedDebug
via the#[derive(Debug, Clone, PartialEq)]
attribute. -
DataType
akaValue
enum — TheValue::LoxCallable
variant was defined asBox<dyn LoxCallable>
.
These were still in place while I was converting code for Chapter 12. I needed
to do some debug logging and happened to add a debug print inside the
Interpreter
’s
visit_call_expr()
method, like so:
fn visit_call_expr(&mut self, expr: Rc<Expr>) -> Result<Value, LoxRuntimeError> {
let call = unwrap_expr!(expr, Call);
let callee: Value = self.evaluate(Rc::clone(call.callee()))?;
println!("Interpreter::visit_call_expr() callee {:?}", callee);
...
}
The variable callee
was a Value::LoxCallable(Box<dyn LoxCallable>)
,
whose concrete type was LoxFunction
. The LoxFunction
’s
closure
was an
Environment
, and its enclosing
field
was also an Environment
.
In other words, Environment
is a recursive structure. This recursive
nesting caused the debug print to explode and eventually led to a stack overflow
when the Interpreter
tried to clone or traverse it. The
println!
statement above produced between 240K and 320K of repeated AST
text before crashing.
💡 The solution was to override Debug
for LoxFunction
to avoid printing the full closure:
-
Removed the
#[derive(Debug)]
attribute frompub struct LoxFunction
. -
Manually implemented
std::fmt::Debug
forpub struct LoxFunction
.
❷ PartialEq
on Box<dyn LoxCallable>
Can Potentially Cause Stack Overflow
This stack overflow occurred when the codebase was still largely in the same state as the first stack overflow. That is:
-
pub struct LoxFunction
— TheLoxFunction
struct derivedPartialEq
via#[derive(Debug, Clone, PartialEq)]
. -
DataType
akaValue
enum — TheValue::LoxCallable
variant was still defined asBox<dyn LoxCallable>
. -
In the manual
PartialEq
implementation forValue
, the comparison forValue::LoxCallable
was written as(DataType::LoxCallable(a), DataType::LoxCallable(b)) => a == b,
🤬. -
The variables involved in the comparison were
Value::LoxCallable(Box<dyn LoxCallable>)
, whose concrete type wasLoxFunction
.
Box<dyn LoxCallable>
doesn’t support PartialEq
out of the box.
So when Rust attempts to evaluate the equality comparison, it will:
-
Try to resolve
PartialEq
for the trait object. -
Since
PartialEq
was derived onLoxFunction
, it will compare all fields. -
That includes the
closure
field, which is anEnvironment
. -
Environment
can contain values that include functions, which contain environments, and so on. This leads to infinite recursion and ultimately a stack overflow.
💡 The solution involved two key fixes:
⓵ Compare the data pointer only—check identity, not equality.
Replace the offending line
(DataType::LoxCallable(a), DataType::LoxCallable(b)) => a == b,
with:
61
62
(Value::LoxCallable(a), Value::LoxCallable(b)) =>
std::ptr::eq(a.as_ref(), b.as_ref()),
See the full updated
impl PartialEq for Value
.
⓶ Manually implement
PartialEq
for LoxFunction
.
This gives you full control over the equality logic: compare closure
by identity,
and declaration
by value.
❸ Box<dyn LoxCallable>
Breaks Pointer Identity
When I ran the author-provided test script test/operator/equals_method.lox, which contains the following:
// Bound methods have identity equality.
class Foo {
method() {}
}
var foo = Foo();
var fooMethod = foo.method;
// Same bound method.
print fooMethod == fooMethod; // expect: true
// Different closurizations.
print foo.method == foo.method; // expect: false
💥 I got false
and false
instead of the expected true
and
false
.
The
manual Clone
implementation for DataType
correctly cloned Box<dyn LoxCallable>
using:
DataType::LoxCallable(callable) => DataType::LoxCallable(callable.clone_box())
.
However, this creates a new boxed object, which breaks pointer identity.
💡 The fix is to use Rc<dyn LoxCallable>
instead of
Box<dyn LoxCallable>
. Since
Rc::clone()
preserves pointer identity, this change ensures correct behavior. See the refactored
pub enum Value
.
With Rc<dyn LoxCallable>
, we can safely derive Clone
using #[derive(Clone)]
, eliminating the need for a manual implementation.
After this change, the test script now produces the correct result.
❹ The New
LoxInstance::get()
and
Interpreter::visit_get_expr()
Methods
The
src/lox_instance.rs
is a new module introduced in
Chapter 12. It defines and implements the LoxInstance
struct,
which is also added as a variant of
pub enum Value
:
LoxInstance(Rc<RefCell<LoxInstance>>)
.
At this stage of development, I ran the author-provided script referenced in
the beginning and got the wrong output:
Foo.init(one)
, Foo.init(two)
, Foo instance
,
and field
. 💥 The last entry should have been init
.
After several debugging iterations, I discovered the issue: in
Interpreter::visit_get_expr()
, I passed a &LoxInstance
to LoxInstance::get()
. That method then created a new
Rc<RefCell<LoxInstance>>
from the borrowed reference,
which broke pointer identity.
However, Interpreter::visit_get_expr()
already had access to the original
Rc<RefCell<LoxInstance>>
. I should have passed that directly to
LoxInstance::get()
instead of a reference. Then, inside
LoxInstance::get()
, I could simply clone it using
Rc::clone()
,
which preserves pointer identity.
This fix allows the script — test/constructor/call_init_explicitly.lox — to run correctly.
❺ Other Side-Effect Refactorings
By side-effect, I mean refactorings triggered by bug fixes in existing code or by the introduction of new code.
⓵ With the addition of the two
LoxCallable
and LoxInstance
variants to the Value
enum,
the former
src/data_type.rs
module
was renamed to
src/value.rs
. The enum is now named Value
;
DataType
has been removed.
⓶ As discussed in
❸, we replaced
Box<dyn LoxCallable>
with Rc<dyn LoxCallable>
in the
src/lox_callable.rs
module. This change allowed us to simplify the module
down to a trait declaration. We also removed the to_string()
method in favor of
Display
. That means any concrete type implementing the LoxCallable
trait
must also implement Display
. See:
-
The existing
src/lox_clock.rs
. -
The existing
src/lox_function.rs
. -
The new
src/lox_class.rs
.
⓷ Up to Chapter 11, the src/environment.rs
module’s
ancestor()
and get_at()
methods
included a name: &Token
parameter. However, the original Java methods
do not.
I’ve been reading and coding chapter by chapter without looking ahead, so I didn’t initially understand
why getAt()
lacked a Token name
in the Java version. In Chapter 12,
getAt()
is called in
LoxFunction.java
without a Token name
, which isn’t available at that point in the code anyway.
To match the Java version, I refactored the
src/environment.rs
module. This involved:
-
Adding a new
get_by_name()
helper method. -
Removing the
name: &Token
parameter from bothancestor()
andget_at()
.
The original purpose of the name: &Token
parameter was to provide error context,
specifically line numbers. But get_at()
is only used for internally resolved variables.
By the time it’s called in the Interpreter
, resolution has already guaranteed the variable exists,
so error context isn’t needed. If it fails, it’s a bug in the Interpreter
, not user code.
So the name: &Token
was unnecessary.
Apart from the
src/lox_instance.rs
module, which we discussed
above, the other code additions
are fairly straightforward and closely mirror the Java version from the book.
⓵ The new
src/lox_class.rs
module — Mirrors the Java version,
though the
call()
method in Rust is particularly interesting.
⓶ The existing
src/parser.rs
module — Mirrors the Java version:
-
Added a new
class_declaration()
method. -
Updated the existing
declaration()
method to invokeclass_declaration()
. -
Updated the existing methods
call()
,assignment()
, andprimary()
as per Chapter 12.
⓷ Implemented the following methods in the existing
src/resolver.rs
module, as described in Chapter 12 —
visit_class_stmt()
,
visit_get_expr()
,
visit_set_expr()
, and
visit_this_expr()
.
⓸ Updated the existing
src/interpreter.rs
module:
-
Implemented the following methods as per Chapter 12:
visit_class_stmt()
,visit_get_expr()
,visit_set_expr()
, andvisit_this_expr()
. -
Updated the existing methods
is_truthy()
andstringify()
to handle the newValue::LoxInstance(Rc<RefCell<LoxInstance>>)
enum variant. -
Updated the existing
look_up_variable()
method — In response to theEnvironment::get_at()
signature refactoring discussed earlier.
● A new module,
tests/test_classes.rs
,
was added. Its structure and conventions follow those of the existing test modules.
It incorporates a relevant set of author-provided
test scripts.
● In the existing module
tests/test_parser.rs
,
additional methods were introduced to test Chapter 12 parsing error logic:
-
Method
test_parser_classes_field_and_property()
and its helperget_classes_field_and_property_script_results()
. -
Method
test_parser_classes_methods_on_classes()
and its helperget_classes_methods_on_classes_script_results()
. -
Method
test_parser_classes_this()
and its helperget_classes_this_script_results()
.
● The update to the existing
tests/test_interpreter.rs
module was minor: a few adjustments
were made in response to the removal of the get_
prefix from
getter methods in the
src/lox_error.rs
module.
That wraps up Chapter 12: Classes. I apologise if this post feels a bit long, but I believe it’s important to document everything thoroughly for my future self—especially after encountering so many bugs in this chapter. I’m still not entirely satisfied with the code; there’s room for improvement, and I may revisit it for refactoring later on.
There are still a few warnings about dead code, which I’m okay with for now.
Only one chapter remains in Part II, and about 10 days left until the end of August 2025.
Hopefully, I’ll be able to complete it by then.
Thanks for reading! I hope this post helps others on the same journey.
As always—stay curious, stay safe 🦊
✿✿✿
Feature image sources:
- https://www.omgubuntu.co.uk/2024/03/ubuntu-24-04-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://www.rust-lang.org/
- https://www.pngitem.com/download/ibmJoR_rust-language-hd-png-download/
- https://craftinginterpreters.com/