[!NOTE] This tutorial details the journey of building a dynamic scripting language in C—from a basic mathematical expression calculator to a language supporting dynamic types, variable bindings, environment scopes, comparison operators,
if/elsecontrol flow, andwhileloops.
Introduction
In static programming languages like C, variable types are fixed at compile time (int, double, char*). In dynamic programming languages (like Python, Lua, or JavaScript), variables do not have types—values have types. A variable can store a number at one moment and a boolean or string at the next.
In this tutorial, we explore how to build a dynamic language interpreter in C step-by-step.
graph TD
A["Step 1: Dynamic Value Representation<br/>(Tagged Union: Number, Bool, Nil)"] --> B["Step 2: Variables & Environment<br/>(Symbol Table & Mutability)"]
B --> C["Step 3: Expressions & Control Flow<br/>(Comparisons, Blocks, If/Else)"]
C --> D["Step 4: Loops & Repeated Execution<br/>(While Loops)"]
Step 1: Dynamic Value System (Tagged Union)
The Concept
To handle multiple data types dynamically at runtime without static types, we use a Tagged Union. A tagged union pairs an enum (the tag) with a union (the payload data).
Implementation
In ast.h, we define ValueType and Value:
#include <stdbool.h>
typedef enum {
VAL_NIL,
VAL_NUMBER,
VAL_BOOL
} ValueType;
typedef struct {
ValueType type;
union {
double number;
bool boolean;
};
} Value;
Helper Constructors
Value value_number(double number) {
return (Value){.type = VAL_NUMBER, .number = number};
}
Value value_bool(bool boolean) {
return (Value){.type = VAL_BOOL, .boolean = boolean};
}
Value value_nil(void) {
return (Value){.type = VAL_NIL};
}
Updating the Evaluator
Rather than returning a primitive C int or double, our evaluation function now returns a dynamic Value:
Value evaluate(ASTNode *node) {
if (node == NULL) return value_number(0);
if (node->type == AST_NUMBER) {
return value_number(node->number_val);
}
if (node->type == AST_BINARY_OP) {
double left = evaluate(node->binary_op.left).number;
double right = evaluate(node->binary_op.right).number;
switch (node->binary_op.op) {
case TOKEN_PLUS: return value_number(left + right);
case TOKEN_MINUS: return value_number(left - right);
case TOKEN_STAR: return value_number(left * right);
case TOKEN_SLASH: return value_number(left / right);
default: exit(1);
}
}
return value_nil();
}
Step 2: Variables & Environment (Symbol Table)
The Concept
To support variable declarations and assignments (e.g. x = 10, y = x + 5), the runtime needs a Symbol Table or Environment that maps string identifiers to dynamic Values.
classDiagram
class Environment {
+Symbol symbols[100]
+int count
+Environment* parentEnv
+env_set(name, value)
+env_get(name, out_val)
}
class Symbol {
+char name[64]
+Value val
}
Environment "1" *-- "many" Symbol
Implementation
1. Defining the Environment Structure
typedef struct {
char name[64];
Value val;
} Symbol;
typedef struct Environment {
Symbol symbols[100];
int count;
struct Environment *parentEnv; // For nested scope chains
} Environment;
2. Environment Mutators & Lookups
void env_set(Environment *env, const char *name, Value val) {
// Update existing symbol if present
for (int i = 0; i < env->count; i++) {
if (strcmp(env->symbols[i].name, name) == 0) {
env->symbols[i].val = val;
return;
}
}
// Otherwise add new symbol
strncpy(env->symbols[env->count].name, name, 64);
env->symbols[env->count].val = val;
env->count++;
}
bool env_get(Environment *env, const char *name, Value *ret_val) {
for (int i = 0; i < env->count; i++) {
if (strcmp(env->symbols[i].name, name) == 0) {
*ret_val = env->symbols[i].val;
return true;
}
}
return false; // Undefined identifier
}
3. AST Nodes for Identifier and Assignment
// AST_IDENTIFIER node reads 'x'
// AST_ASSIGNMENT node writes 'x = expr'
if (node->type == AST_ASSIGNMENT) {
Value val = evaluate(node->assignment.expr);
env_set(env, node->assignment.name, val);
return val;
}
if (node->type == AST_IDENTIFIER) {
Value val;
if (env_get(env, node->identifier_name, &val)) {
return val;
}
printf("Runtime Error: Undefined identifier '%s'\n", node->identifier_name);
exit(1);
}
Step 3: Comparison Operators & Control Flow (if / else)
The Concept
Control flow requires two features:
- Comparison Expressions: Operators (
<,>,==) that evaluate toVAL_BOOL. - Block Statements & Branching: Executing statements inside
{ ... }blocks based on truthiness.
x = 10
if x > 5 {
y = 100
} else {
y = 0
}
y
Implementation
1. Adding Lexer Keywords & Two-Character Tokens
// Two-character equality '=='
if (current == '=') {
if (**scpy == '=') {
(*scpy)++;
return (Token){.type = TOKEN_EQUAL};
}
return (Token){.type = TOKEN_ASSIGN};
}
// Single character comparisons & braces
case '<': return (Token){.type = TOKEN_LESS_THAN};
case '>': return (Token){.type = TOKEN_GREATER_THAN};
case '{': return (Token){.type = TOKEN_LBRACE};
case '}': return (Token){.type = TOKEN_RBRACE};
Keywords like "if" and "else" are detected when scanning identifiers:
if (strcmp(buf, "if") == 0) return (Token){.type = TOKEN_IF};
if (strcmp(buf, "else") == 0) return (Token){.type = TOKEN_ELSE};
2. Parsing Blocks { ... } and if Expressions
ASTNode *astparse_block() {
advance(); // Consume '{'
ASTNode **stmts = malloc(sizeof(ASTNode*) * 64);
int count = 0;
while (current_token.type != TOKEN_RBRACE && current_token.type != TOKEN_EOF) {
stmts[count++] = astparse_expression();
}
advance(); // Consume '}'
return astnode_create_block(stmts, count);
}
ASTNode *astparse_if() {
advance(); // Consume 'if'
ASTNode *condition = astparse_expression();
ASTNode *then_branch = astparse_block();
ASTNode *else_branch = NULL;
if (current_token.type == TOKEN_ELSE) {
advance(); // Consume 'else'
if (current_token.type == TOKEN_IF) {
else_branch = astparse_if(); // Chain "else if"
} else {
else_branch = astparse_block();
}
}
return astnode_create_if(condition, then_branch, else_branch);
}
3. Evaluating AST_IF and Truthiness
In dynamic languages, both true booleans and non-zero numbers are considered truthy:
if (node->type == AST_IF) {
Value cond = evaluate(node->if_stmt.condition);
// Evaluate truthiness
bool is_truthy = (cond.type == VAL_BOOL && cond.boolean) ||
(cond.type == VAL_NUMBER && cond.number != 0);
if (is_truthy) {
return evaluate(node->if_stmt.then_branch);
} else if (node->if_stmt.else_branch != NULL) {
return evaluate(node->if_stmt.else_branch);
}
return (Value){.type = VAL_NIL};
}
Step 4: Loops & Repeated Execution (while)
The Concept
A while loop continuously evaluates its condition expression, and as long as that condition remains truthy, it re-evaluates its body block { ... }.
i = 0
sum = 0
while i < 5 {
sum = sum + i
i = i + 1
}
sum
Implementation
1. Adding TOKEN_WHILE & AST_WHILE
In ast.h, update TokenType and ASTNodeType:
typedef enum {
// ... existing tokens ...
TOKEN_WHILE
} TokenType;
typedef enum {
// ... existing AST types ...
AST_WHILE
} ASTNodeType;
// ASTNode union addition:
struct {
struct ASTNode *condition;
struct ASTNode *body;
} while_loop;
2. Lexer & Parser for while
In the lexer identifier scanner:
if (strcmp(buf, "while") == 0) return (Token){.type = TOKEN_WHILE};
In the parser:
ASTNode *astparse_while() {
advance(); // Consume 'while'
ASTNode *condition = astparse_expression();
ASTNode *body = astparse_block();
return astnode_create_while(condition, body);
}
In astparse_factor():
if (current_token.type == TOKEN_WHILE) {
return astparse_while();
}
3. Evaluating AST_WHILE
At runtime, evaluation uses a C while loop to re-evaluate the condition and body until the condition turns falsy:
if (node->type == AST_WHILE) {
Value last_val = (Value){.type = VAL_NIL};
while (1) {
Value cond = evaluate(node->while_loop.condition);
bool is_truthy = (cond.type == VAL_BOOL && cond.boolean) ||
(cond.type == VAL_NUMBER && cond.number != 0);
if (!is_truthy) break;
last_val = evaluate(node->while_loop.body);
}
return last_val;
}
Summary of Execution Flow
[!TIP] With all 4 steps implemented, our dynamic language now runs loops and state updates seamlessly:
Input:
i = 0
sum = 0
while i < 5 {
sum = sum + i
i = i + 1
}
sum
Output:
10
Future Horizons
- Functions (
fn) & Stack Frames: Parameters, local scopes, recursion, and return statements. - Dynamic Objects & Arrays: Structs, tables, and heap memory management.
- Garbage Collection: Simple Mark-and-Sweep or reference counting GC for strings and objects.