11. pyxc: Mutable Variables
Next: let a name change its value.
Right now NamedValues maps each name directly to an LLVM value. That works while every name is immutable, but assignment needs a stable place to write:
x = x + 1
The clean first model is:
name -> stack slot
read -> load from slot
write -> store to slot
Do not build PHI nodes for mutable variables by hand. Emit simple memory operations first, then let LLVM's mem2reg optimization recover SSA form.
By the end of this chapter, this should work:
ready> var x = 1: x = x + 1
Expected:
Parsed a top-level expression.
Evaluated to 2.000000
Work in:
cd code/chapter-11
11.1 Extend the Grammar
Start with the language boundary.
Replace:
expression = comparison ;
with:
expression = variable-expression
| comparison [ "=" expression ] ;
variable-expression = "var" variable-binding
{ "," variable-binding } ":"
[ end-of-lines ] expression ;
variable-binding = name [ "=" expression ] ;
Then change the loop rule from:
for-expression = "for" name "=" expression ","
expression "," expression ":"
[ end-of-lines ] expression ;
to:
for-expression = "for" [ "var" ] name "=" expression ","
expression "," expression ":"
[ end-of-lines ] expression ;
The last loop expression is now the complete update:
for var i = 1, i <= 3, i = i + 1: printd(i)
Chapter 10 treated it as a numeric step and added it automatically. Chapter 11 evaluates i = i + 1 like any other expression.
Assignment belongs at the loosest precedence level. This makes it right-associative:
a = b = 4
parses as:
a = (b = 4)
11.2 Add the var Token
Add one token after tok_for:
// mutable variables
tok_var = -16,
Then add it to Keywords:
{"var", tok_var}
Also add it to the debug token-name table:
{tok_var, "'var'"}
No lexer branch is needed. var follows the same identifier-or-keyword path as def, if, and for.
Build now:
cmake -S . -B build
cmake --build build
The lexer now recognizes var; the parser does not yet know what to construct from it.
11.3 Give Expressions an Lvalue Boundary
Assignment needs to distinguish a location from a computed value:
lvalue = rvalue
For now, only a plain variable name is an lvalue. Add this virtual query to ExpressionNode:
class ExpressionNode {
public:
virtual ~ExpressionNode() = default;
virtual const string *getLValueName() const { return nullptr; }
virtual Value *codegen() = 0;
};
The default says that numbers, calls, binary expressions, and control-flow expressions are not assignable.
Override it in NameExpressionNode:
class NameExpressionNode : public ExpressionNode {
string Name;
public:
NameExpressionNode(const string &Name) : Name(Name) {}
const string *getLValueName() const override { return &Name; }
Value *codegen() override;
};
This keeps the parser independent of concrete AST casts. It asks the parsed left side whether it denotes an assignable name.
11.4 Add the Assignment AST Node
Add a node containing the destination name and right-hand expression:
class AssignmentExpressionNode : public ExpressionNode {
string VariableName;
unique_ptr<ExpressionNode> Expression;
public:
AssignmentExpressionNode(const string &VariableName,
unique_ptr<ExpressionNode> Expression)
: VariableName(VariableName), Expression(std::move(Expression)) {}
Value *codegen() override;
};
An assignment is itself an expression. After storing the new value, it returns that value. That is what makes chained assignment possible.
11.5 Parse Assignment
ParseExpression() currently returns ParseComparison() directly.
Replace it with:
static unique_ptr<ExpressionNode> ParseExpression() {
if (CurrentToken == tok_var)
return ParseVariableExpression();
auto Expr = ParseComparison();
if (!Expr)
return nullptr;
if (CurrentToken != tok_assign)
return Expr;
const string *AssignedName = Expr->getLValueName();
if (!AssignedName)
return LogErrorExpression("Destination of '=' must be a variable");
string Name = *AssignedName;
getNextToken(); // eat '='
auto Right = ParseExpression();
if (!Right)
return nullptr;
return make_unique<AssignmentExpressionNode>(Name, std::move(Right));
}
Add a forward declaration for ParseVariableExpression() with the other parser declarations:
static unique_ptr<ExpressionNode> ParseVariableExpression();
Notice the recursive call on the right side. That is the small detail that makes assignment associate right to left.
The lvalue check rejects this immediately:
(1 + 2) = 3
Expected diagnostic:
Destination of '=' must be a variable
11.6 Change NamedValues from Values to Slots
This is the central implementation change.
Replace:
static std::map<string, Value *> NamedValues;
with:
static std::map<string, AllocaInst *> NamedValues;
Before this change:
NamedValues["x"] -> current LLVM value
After this change:
NamedValues["x"] -> memory slot containing current value
Every variable kind will use the same representation:
parameter -> alloca
var local -> alloca
loop variable -> alloca
11.7 Create Slots in the Entry Block
Add this helper near the code-generation globals:
static AllocaInst *CreateEntryBlockAlloca(Function *TheFunction,
const string &VariableName) {
IRBuilder<> TmpB(&TheFunction->getEntryBlock(),
TheFunction->getEntryBlock().begin());
return TmpB.CreateAlloca(Type::getDoubleTy(*TheContext), nullptr,
VariableName);
}
Use a temporary builder so the allocation always goes at the start of the function's entry block, even when the current builder is generating a nested branch or loop.
This produces deliberately simple IR:
%x = alloca double
store double 1.000000e+00, ptr %x
%x1 = load double, ptr %x
The optimization pipeline will clean it up later.
11.8 Make Variable Reads Load
NameExpressionNode::codegen() used to return the value stored directly in NamedValues.
Replace it with:
Value *NameExpressionNode::codegen() {
auto VariableBinding = NamedValues.find(Name);
if (VariableBinding == NamedValues.end() || !VariableBinding->second)
return LogErrorValue("Unknown variable name: '" + Name + "'");
return TheBuilder->CreateLoad(Type::getDoubleTy(*TheContext),
VariableBinding->second, Name.c_str());
}
A name expression is an rvalue use, so it loads the current value from the slot.
11.9 Make Assignment Store
Implement the new node:
Value *AssignmentExpressionNode::codegen() {
Value *Value = Expression->codegen();
if (!Value)
return nullptr;
auto VariableBinding = NamedValues.find(VariableName);
if (VariableBinding == NamedValues.end() || !VariableBinding->second)
return LogErrorValue("Unknown variable name: '" + VariableName + "'");
TheBuilder->CreateStore(Value, VariableBinding->second);
return Value;
}
The order is important:
evaluate right side -> find destination slot -> store -> return stored value
Do not return the slot. The value of x = 5 is 5, not the address of x.
11.10 Add the var AST Node
A var expression owns a list of bindings and one body:
class VariableExpressionNode : public ExpressionNode {
vector<pair<string, unique_ptr<ExpressionNode>>> VariableBindings;
unique_ptr<ExpressionNode> Body;
public:
VariableExpressionNode(
vector<pair<string, unique_ptr<ExpressionNode>>> VariableBindings,
unique_ptr<ExpressionNode> Body)
: VariableBindings(std::move(VariableBindings)),
Body(std::move(Body)) {}
Value *codegen() override;
};
This form:
var x = 1, y = x + 1: y
has two bindings. The second initializer must see the first binding, so parse and generate them in source order.
11.11 Parse var
Add ParseVariableExpression():
static unique_ptr<ExpressionNode> ParseVariableExpression() {
getNextToken(); // eat 'var'
vector<pair<string, unique_ptr<ExpressionNode>>> VariableBindings;
while (true) {
if (CurrentToken != tok_name)
return LogErrorExpression("Expected name after 'var'");
string ParsedName = Name;
getNextToken(); // eat name
unique_ptr<ExpressionNode> Init;
if (CurrentToken == tok_assign) {
getNextToken(); // eat '='
Init = ParseExpression();
if (!Init)
return nullptr;
} else {
Init = make_unique<NumberExpressionNode>(0.0);
}
VariableBindings.push_back({ParsedName, std::move(Init)});
if (CurrentToken != tok_comma)
break;
getNextToken(); // eat ','
}
if (CurrentToken != tok_colon)
return LogErrorExpression("Expected ':' after var bindings");
getNextToken(); // eat ':'
consumeNewlines();
auto Body = ParseExpression();
if (!Body)
return nullptr;
return make_unique<VariableExpressionNode>(
std::move(VariableBindings), std::move(Body));
}
A missing initializer defaults to 0.0:
var x: x
11.12 Generate Slots for var
Implement VariableExpressionNode::codegen():
Value *VariableExpressionNode::codegen() {
vector<pair<string, AllocaInst *>> OldBindings;
Function *TheFunction = TheBuilder->GetInsertBlock()->getParent();
for (auto &Var : VariableBindings) {
const string &VariableName = Var.first;
ExpressionNode *Init = Var.second.get();
Value *InitVal = Init->codegen();
if (!InitVal)
return nullptr;
AllocaInst *Alloca =
CreateEntryBlockAlloca(TheFunction, VariableName);
TheBuilder->CreateStore(InitVal, Alloca);
OldBindings.push_back({VariableName, NamedValues[VariableName]});
NamedValues[VariableName] = Alloca;
}
Value *BodyVal = Body->codegen();
if (!BodyVal)
return nullptr;
for (auto SavedBinding = OldBindings.rbegin();
SavedBinding != OldBindings.rend(); ++SavedBinding) {
const string &Name = SavedBinding->first;
AllocaInst *PreviousValue = SavedBinding->second;
if (PreviousValue)
NamedValues[Name] = PreviousValue;
else
NamedValues.erase(Name);
}
return BodyVal;
}
Each initializer is generated before installing its own binding. Then the new binding is visible to later initializers and the body.
Restore bindings in reverse order. That correctly handles repeated shadowing, including repeated names in one binding list.
Build and run the first complete experiment:
cmake --build build
./build/pyxc
ready> var x = 1: x = x + 1
ready> var x = 1, y = x + 1: y
ready> var x: x
Expected:
Parsed a top-level expression.
Evaluated to 2.000000
Parsed a top-level expression.
Evaluated to 2.000000
Parsed a top-level expression.
Evaluated to 0.000000
11.13 Put Parameters in Slots Too
After changing NamedValues, function parameters can no longer map directly to incoming Argument values.
In FunctionDefinitionNode::codegen(), replace the parameter setup with:
NamedValues.clear();
for (auto &Argument : TheFunction->args()) {
AllocaInst *Alloca =
CreateEntryBlockAlloca(TheFunction, string(Argument.getName()));
TheBuilder->CreateStore(&Argument, Alloca);
NamedValues[string(Argument.getName())] = Alloca;
}
Now parameters use the same read and write path as locals:
ready> def increment(n): n = n + 1
ready> increment(5)
Expected:
Parsed a function definition.
Parsed a top-level expression.
Evaluated to 6.000000
11.14 Change for from Step to Update
Update ForExpressionNode so it stores a complete Update expression and whether the loop declares a new variable:
class ForExpressionNode : public ExpressionNode {
string VariableName;
bool DeclaresVariable;
unique_ptr<ExpressionNode> Start, Condition, Update, Body;
public:
ForExpressionNode(const string &VariableName, bool DeclaresVariable,
unique_ptr<ExpressionNode> Start,
unique_ptr<ExpressionNode> Condition,
unique_ptr<ExpressionNode> Update,
unique_ptr<ExpressionNode> Body)
: VariableName(VariableName), DeclaresVariable(DeclaresVariable),
Start(std::move(Start)), Condition(std::move(Condition)),
Update(std::move(Update)), Body(std::move(Body)) {}
Value *codegen() override;
};
At the start of ParseForExpression(), consume the optional var:
bool DeclaresVariable = false;
if (CurrentToken == tok_var) {
DeclaresVariable = true;
getNextToken(); // eat 'var'
}
Rename the parsed Step expression to Update, then pass DeclaresVariable and Update into the AST node.
The semantic distinction is:
for var i = ... # create a new loop-local slot
for i = ... # reuse an existing slot
11.15 Generate the Mutable Loop
In ForExpressionNode::codegen(), evaluate the start value first. Then either create a new slot or find the existing one:
AllocaInst *LoopVariableSlot = nullptr;
AllocaInst *PreviousVariableSlot = nullptr;
if (DeclaresVariable) {
auto PreviousBinding = NamedValues.find(VariableName);
if (PreviousBinding != NamedValues.end())
PreviousVariableSlot = PreviousBinding->second;
LoopVariableSlot = CreateEntryBlockAlloca(TheFunction, VariableName);
TheBuilder->CreateStore(StartVal, LoopVariableSlot);
NamedValues[VariableName] = LoopVariableSlot;
} else {
auto ExistingBinding = NamedValues.find(VariableName);
if (ExistingBinding == NamedValues.end() || !ExistingBinding->second)
return LogErrorValue("Unknown variable name: '" + VariableName + "'");
LoopVariableSlot = ExistingBinding->second;
TheBuilder->CreateStore(StartVal, LoopVariableSlot);
}
Keep the condition, body, and after blocks from Chapter 10. Remove the manual PHI and numeric-step addition. At the end of the body, generate the complete update expression:
if (!Update->codegen())
return nullptr;
TheBuilder->CreateBr(CondBB);
After the loop, restore the shadowed binding only when the loop declared a new variable:
if (DeclaresVariable) {
if (PreviousVariableSlot)
NamedValues[VariableName] = PreviousVariableSlot;
else
NamedValues.erase(VariableName);
}
Run the immediate experiment:
cmake --build build
./build/pyxc
ready> extern def printd(x)
ready> for var i = 1, i <= 3, i = i + 1: printd(i)
Expected:
Parsed an extern.
Parsed a top-level expression.
1.000000
2.000000
3.000000
Evaluated to 0.000000
11.16 Build an Accumulator
Function bodies still contain only one expression, so use the loop's 0.0 result to sequence the loop before the final value:
ready> def sum_to(n): var acc = 0: (for var i = 1, i <= n, i = i + 1: acc = acc + i) + acc
ready> sum_to(5)
Expected:
Parsed a function definition.
Parsed a top-level expression.
Evaluated to 15.000000
The + acc is temporary glue. The loop runs for its side effects and returns 0.0, so the addition returns the accumulated value. Chapter 12 will replace this with real statement blocks.
11.17 Let mem2reg Recover SSA
The source model uses memory because it is simple and correct:
alloca -> load -> store
LLVM's promotion pass can turn eligible entry-block allocas back into SSA values and insert PHI nodes where control flow merges.
Without promotion, an update looks roughly like:
%x = alloca double
store double 1.000000e+00, ptr %x
%x1 = load double, ptr %x
%addtmp = fadd double %x1, 1.000000e+00
store double %addtmp, ptr %x
After promotion, it can become just:
%addtmp = fadd double 1.000000e+00, 1.000000e+00
For loops and branches, the pass inserts the necessary PHI nodes across the whole control-flow graph. That is precisely the global bookkeeping we do not want each AST node to reproduce.
Use -v to inspect the optimized IR:
./build/pyxc -v
Then enter a mutable-variable example. The final IR should contain SSA values and PHI nodes rather than a load and store for every source-level operation.
11.18 Run the Chapter Tests
Run the complete suite:
llvm-lit -v test/
Pay particular attention to tests covering:
variable declarations
default initialization
multiple bindings
assignment values
right-associative assignment
invalid assignment destinations
parameter mutation
for var declarations
reuse of existing loop variables
variable shadowing
Also try the error boundary directly:
ready> (1 + 2) = 3
Expected:
Error (Line 1, Column 9): Destination of '=' must be a variable
11.19 What You Built
Chapter 11 now has one consistent mutable-variable model:
declaration -> entry-block alloca + initial store
read -> load
assignment -> store and return assigned value
scope exit -> restore previous name binding
optimization -> promote memory back to SSA
That is the useful boundary:
simple frontend memory model -> LLVM optimization -> clean SSA
Next: Chapter 12 adds statement blocks, so accumulators no longer need the for(...) + result sequencing trick.
11.20 Need Help?
Build issues? Questions?
Include:
- Your operating system and version
- The chapter number
- The exact command you ran
- The complete error message
- The output of
c++ --versionandcmake --version - The output of
llvm-config --versionfor Chapter 6 and later