Chapter 1 learning smol standard model of languages
Link: https://smol-tutor.xyz/tutor/.
def1
SMoL - Standard Model of Languages
- Captures the shared computational core across widely-used languages (C#, Java, JavaScript, Python, Scala, Racket, and more).
- 8 core features:
- Lexical scope.
- Nested scope.
- Eager evaluation.
- Sequential evaluation (per thread).
- Mutable first-order variables.
- Mutable first-class structures (objects, vectors, etc.).
- Higher-order functions with closures.
- Automated memory management (garbage collection).
- Inclusion criteria: Feature must be present across many diverse languages.
- Excluded: static types (not universal), objects (too much variation).
- Descriptive, not prescriptive - models what languages are, not what they should be.
Variable definition -> creates a binding between a name and a value.
lisp(defvar x 1)pythonx = 1- Binding:
xbound to1. - Lisp syntax:
(operator arg1 arg2 ...)- prefix notation, everything parenthesized.
- Binding:
Function definition
lisp(deffun (f x y) body)jsxfunction f(x, y) { return body; }- Formal parameters -> variables in the definition.
- Actual parameters (arguments) -> values passed at call time.
Nested definitions: function bodies can contain variable definitions.
(defvar n (+ x y))inside function ->nis a local variable.
Block -> a sequence of definitions and expressions.
- Two places definitions can appear: top-level block and function bodies.
- Blocks form a tree-like (nested) structure.
Values -> typical computation results: numbers, strings, booleans, etc.
Error -> another possible outcome of running a program.
- Errors halt execution immediately - subsequent expressions don't run.
Sequential evaluation: expressions run in order until completion or error.
def2
Variable lookup follows the hierarchical block structure (lexical scope).
Lookup order:
- Check current block.
- If not found -> check enclosing block (where current block is defined).
- Repeat recursively outward.
- Top-level block's parent -> primordial block.
- If still not found -> error.
Primordial block -> invisible block enclosing top-level; contains language built-ins (e.g.,
+,/,print).This is lexical/static scoping: variable resolution based on code structure, not runtime call stack.
Scope -> the region of a program where a variable can be referenced.
- Scope includes: the block where it's defined + all sub-blocks.
- Shadowing -> when an inner block defines a variable with the same name as an outer block, the inner definition "shadows" (hides) the outer one.
def3
Variables are bound to values (not expressions).
Eager evaluation: expressions are evaluated immediately at definition time.
(defvar x (+ 1 2))->xis bound to3, not to(+ 1 2).- Happens even if the variable is never used later.
Function calls: arguments evaluated immediately before binding to formal parameters.
- Even if a parameter is never used in the function body.
Sequential evaluation: blocks execute in reading order (top-to-bottom, left-to-right).
post1
- Nested functions can access variables from all enclosing scopes (lexical scope chain).
- Local variables are not visible outside their defining block.
- Referencing an undefined variable -> error.
- Definitions processed top-to-bottom - can't use a variable before it's defined.
- Eager evaluation: RHS expression must be computable at definition time.
vectors1
Mutable values -> values that can be changed after creation (e.g., vectors).
Vector -> an ordered collection that holds references to other values.
lisp(mvec ...)python[...]Vectors can contain: numbers, other vectors, or be empty.
Examples:
(mvec 1 2)->vec[1, 2](two elements).(mvec 3)->vec[3](one element).(mvec)->vec[](empty).(mvec (mvec 1 2) (mvec 3) (mvec))->vec[vec[1,2], vec[3], vec[]](nested - holds references to the other three).- Vectors hold references to values, not copies.
lisp(vec-ref vec idx)pythonvec[idx]- 0-indexed: first element is at position
0.
Mutation -> changing the contents of a value after it's created.
lisp(vec-set! vec idx val)pythonvec[idx] = val!convention in Lisp: functions ending with!typically mutate something.- The vector itself isn't replaced; its contents are modified in place.
vectors2
Aliasing -> multiple references pointing to the same vector (no copying).
Vectors are never implicitly copied:
- Assigning to a new variable -> same vector.
- Passing to a function -> same vector.
- Nesting in another vector -> same vector.
Heap -> memory region where vectors live; maps addresses to vectors.
Heap address -> unique identifier for each vector (e.g.,
@100,@200)- Notation only - actual numbers are arbitrary/random.
Bindings vs. Heap are independent:
- Creating a vector doesn't require a variable binding.
- Creating a binding doesn't modify the heap.
Implication: mutating a vector through one reference affects all aliases.
vectors3
Heap notation & Rules
@ddd(e.g.,@100,@200) -> heap addresses (arbitrary numbers)#(...)-> vector contents at that address- Heap maps addresses only to vectors (not primitives like
33) - Vectors store values (numbers or addresses), not printed representations like
#(3 4).
post2
- Assigning a vector to another variable creates an alias, not a copy - mutations via one affect the other.
- Can't reference a variable before its definition (eager, sequential evaluation).
- Passing a vector to a function passes the reference - mutations inside the function affect the original.
- Nested functions access enclosing scopes (lexical scope chain).
- Local variables inside functions are not visible at top-level -> error.
- Vectors can reference themselves (self-referential structures are valid).
- Nested vectors store references - mutating an inner vector is visible through the outer vector's reference.
mutvars1
- Variable assignment (
set!) mutates a variable's binding to a new value. - Variables are not aliased - assignment affects only the mutated variable, not others.
- Assigning
y = xcopies the value, not a reference to the variable - later changes toxdon't affecty. - Vectors capture values at creation time - later variable reassignment doesn't affect stored values.
- Reassigning a variable holding a vector creates a new binding, doesn't affect other references to the old vector.
- Function parameters are independent bindings - mutating one doesn't affect the other.
- Key distinction:
set!changes which value a variable points to;vec-set!changes contents of the value itself.
mutvars2
- Variable assignment (
set!) mutates existing bindings - does not create new bindings; assigning to an undefined variable errors. - Functions see the latest values of outer variables at call time, not the values from when the function was defined.
- Environments (not blocks) bind variables to values - created dynamically at runtime like vectors on the heap.
- Every function call creates a new environment - same function called twice = two separate environments with independent bindings.
- What creates bindings: definitions and function calls (for parameters).
- What mutates bindings: only variable mutations (
set!).
post3
- Vectors are aliased - multiple variables pointing to the same vector share it; mutations via one affect all.
- Variable assignment (
set!) only rebinds that variable - doesn't affect other variables or vectors that already captured the old value. - Vectors passed to functions share the same reference - mutating vector contents inside a function affects the original.
- Function parameters are separate bindings - mutating a parameter doesn't affect the caller's variable.
- Functions always see the current value of outer variables at call time, not values from when the function was defined.
refactor
- Each
(mvec ...)call creates a new, distinct vector - reusing a variable instead of callingmvecagain introduces aliasing. - Aliasing can break code - if code expects two independent vectors, making them share the same reference causes mutations to affect both.
- Passing the same variable vs. a fresh vector to a function - can break if the function mutates the argument (original vector gets modified).
- Order of definitions matters - can't reference a variable before it's defined.
- Safe refactorings: copying element-by-element into a new vector preserves independence; reusing variables for immutable/primitive values is safe.
lambda1
- Functions are first-class values - they can be treated like any other value (numbers, vectors, etc.).
- Functions can be: bound to variables, passed as arguments, returned from other functions, and stored in vectors.
- A function stored in a variable can be called via that variable - multiple variables can reference the same function.
lambda2
- Closures - functions remember the environment in which they were defined, "closing over" the bindings available at creation time
- Each function call creates a new environment - calling the same outer function twice produces two closures with independent bindings
- Closures can mutate variables in their enclosing environment, and each closure's mutations affect only its own captured environment
- Closures see the current value of outer variables when called - if an outer variable is mutated after the closure is created, the closure sees the updated value
lambda3
Lambda expressions create anonymous functions (functions without names).
deffunis shorthand fordefvar+lambda:(deffun (f x y z) body)≡(defvar f (lambda (x y z) body)).
post4
- Closures see the current value of outer variables at call time - mutations to outer variables are visible to the closure.
- Variables are not aliased - assignment copies values, not references to bindings.
- Different calls to a function create different closures with independent environments.
- Mutating a parameter doesn't affect the caller's variable.
- Reassigning a variable doesn't affect vectors/closures that already captured the old value.
begin (lispy)
beginevaluates sub-expressions left-to-right and returns the value of the last expression.Example:
(begin 2 3 4)->4.ifrequires exactly 3 sub-expressions: condition, then-branch, else-branch - too few or too many is a syntax error.Use
beginto group multiple expressions into a single expression (useful forifbranches with side effects).(if cond thn els)- only one expression per branch allowed.(if (eq? n 0) 1 (begin (set! x 1) (* n x)))- usebeginfor multiple expressions.
local (lispy)
let basics:
- Binds variables to values for use within its body
- Bindings are local (not accessible outside the
let) - Syntax requires brackets:
([var expr])not(var expr)
let scoping rule:
- Left-hand-side variables are only in scope within the body
- Right-hand-side expressions cannot reference any of the
letbound variables - This means recursive functions and cross-references between bindings won't work
letrec scoping rule:
- All left-hand-side variables are in scope for all right-hand-side expressions
- Enables recursive and mutually recursive function definitions
- All left-hand-side variable names must be distinct
let* scoping rule:
- Each left-hand-side variable is in scope for all subsequent right-hand-side expressions
- Allows sequential/chained bindings where later bindings can reference earlier ones
- Same variable name can be rebound multiple times
Key distinction:
let: body only.letrec: all RHS expressions + body.let*: subsequent RHS expressions + body.