Skip to content
HN On Hacker News ↗

Needed 1+1, Built a Functional Programming Language

▲ 151 points • 80 comments • by birdculture • 1w ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI and human-written content.

16 %

AI likelihood · overall

Mixed
85% human-written 15% AI-generated
SEGMENTS · HUMAN 1 of 2
SEGMENTS · AI 1 of 2
WORD COUNT 1,302
PEAK AI % 88% · §2
Analyzed
Sep 30
backend: pangram/v3.3
Segments scanned
2 windows
avg 651 words each
Distribution
85 / 15%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,302 words · 2 segments analyzed

Human AI-generated
§1 Human · 7%

table of contentsTHE DATA STRUCTURES ASSIGNMENTWe can add vars to this. It wouldn’t be a big changeActually implementing it in CArena AllocatorMaking the Env TableUPGRADING THE MEMORY ALLOCATORBUILDING THE GARBAGE COLLECTORWhat to expect in the next partsWhat have we achieved so far?WAIT. BUT DOES IT EVALUATE 1+1 I was given a data structures problem of converting an arithmetic expression into a binary tree. Naturally, I decided to build an evaluator. A few days later I implemented closures, a garbage collector, a custom memory allocator, a REPL, an FFI, and a whole bunch of other stuff in C. graphLang THE DATA STRUCTURES ASSIGNMENT The problem was: Evaluate 1 + 1 + 1 to 3 using a binary tree. How do we get there? Well, we first form our tree for 1+1+1 (+) / \ (+) (1) / \ (1) (1) The operator becomes the root, with its two operands as children. Now let’s evaluate this tree. First, we evaluate the root’s left operand. It’s another + expression, so we have to collapse it down to a value before the outer + can execute. (+) / \ (2) (1) Then we evaluate again. (3) <--- that's our result We just performed the equivalent of (+ (+ 1 1) 1) | v (+ 2 1) | v (3) But notice what the evaluator had to know to do this: what + means. One way to represent this is to make every operation a different case in our expression type: Expr ::= Add Expr Expr | Sub Expr Expr | Mul Expr Expr | Div Expr Expr | Val But what do these different cases actually represent? And does the evaluator really need to know the difference between Add and Sub? Then I started implementing our sum types. And when I looked at the structure: Add: Expr x Expr → Expr Sub: Expr x Expr → Expr Mul: Expr x Expr → Expr Div: Expr x Expr → Expr They all take two expressions and produce one expression. So why should the evaluator care whether the operation is Add, Sub, Mul, or Div? Seems like it doesn’t. So now we can just represent our expression as: Expr ::= Func Expr Expr | Val The evaluator doesn’t need to know what a function does. It only needs to know how to apply one. We can add vars to this. It wouldn’t be a big change Should be a tiny addition, no problem whatsoever. I mean variables are just a hash table lookup that gives you an Expr. Oh wait. C doesn’t have built-in hash tables. hmmm.(´-`).。oO( … ) Let’s just implement a hashtable. It’s a small change! m9(・∀・) Soo…how does that work? I never implemented it before. I look it up on Google like a caveman and find this amazing text. How to implement a hash table (in C) So now we just got a little change in the Expr: Expr ::= Func Expr Expr | Val | Var Would you look at that! We have variables now that can be passed to functions once evaluated. Just like (+ 1 1) Actually implementing it in C Alright then, time to code in C with this plan. Seems simple enough. Just a tagged union. typedef enum { LITERAL, VAR, FUNC, } NodeType; struct Node { struct Node *left; struct Node *right; union { int literal; char *var; char *func; } data; NodeType type; }; Right now the mem size of each ( assuming 64 bit system) is: +------------------------+----------+ | Field | Size | +------------------------+----------+ | Left Pointer | 8 bytes | | Right Pointer | 8 bytes | | Data | 8 bytes | | Type | 4 bytes | | Padding | 4 bytes | +------------------------+----------+ | Total | 32 bytes | +------------------------+----------+ 32 Bytes might not seem like a lot, but we gotta think about how this is being used. For evaluating 1+1, we would need 3 nodes. 1 for the operator 2 for the operands That would be 32 x 3= 96 bytes to evaluate 1+1. But here’s the thing. On my system, when you malloc() a node, it adds metadata, which takes up 16 bytes of memory! Bringing our total per node to 32 (node size) + 16 (malloc header) = 48 bytes! So for our 3 nodes to evaluate a (+) we would need 144 bytes! And notice we’re going to be doing a lot of little individual allocations. We need a better way to allocate these nodes. We clearly need a custom allocator. So, I look up what allocator we can use, again like a caveman, and I decide I will be writing an Arena Allocator. Arena Allocator The idea of an arena allocator is pretty simple. All you do is take a big chunk of memory at the start, allocate stuff yourself, and then at the end just free the entire block. So my arena allocator would just be: #define SIZE 1024 Node arena[SIZE] And when we allocate a node, we can just keep track of the top using. int top = 0; When we want to allocate a node, we just return. &arena[top++]; I wrote the allocator and defined a C function to allocate nodes: Node *allocNode(); It was great! Now moving on to actually calling the functions. Then I realized We can store vars and funcs in the same environment! That means functions can just be values too (°◇°) Remember the hashtable we created earlier? It’s time to upgrade it. Making the Env Table In the Env table we are storing two things. Vars and functions.

§2 AI · 88%

But what are functions? As far as our evaluator is concerned, it is a thing that consumes arguments on one side and spits out a result on the other. (func node) / \ (arg 1) (arg 2) The initial idea was that they would just be pointers to C funcs. Seems simple enough. But there is a huge problem with this: how do users write their own functions? But there’s another problem. When a user types code into our language, it can’t magically become a native C function pointer. It can only build an AST (a tree of nodes). If functions are just C pointers, they immediately become opaque values. What happens when a function returns another function? We need to be able to put that function back into the graph and evaluate it later. But a C function pointer isn’t something our evaluator can walk through. Thus we need a type of node that tells the evaluator “Hey, I am a function, but my code isn’t a C pointer; it is this tree right here.” It needs to store the body of the function as a tree, so the evaluator can evaluate it over multiple steps. And for our purposes, that is our closure representation. Instead of a black-box C function, a closure is an actual node in our graph. It holds the parameter on one side, and the tree of operations (the body) on the other. (closure) / \ (parameter) (body) / \ (math) (literal) (technically closures also have an environment, but we haven’t gotten to local vars yet) By making the function an actual node, we can pass it around, return it from other functions, and evaluate it step-by-step whenever we want! Now we have functions users can define themselves without ever touching the C code!!! Alright then, let’s actually implement this in C. So what do we need? If variables and functions are both values, the environment needs to map names to nodes. So now we can create our env entry as typedef struct EnvEntry { char *key; Node *val; struct EnvEntry *next; } EnvEntry; Now our hash table maps the names (key) to the Node * which is the value. But what is val? Val is a Node!