Static Analysis for the Uninitialized
Oct 18, 2026 @ 8:20 pmI really enjoy learning new things in my free time. Lately I’ve been studying this book on Static Program Analysis. The book is very good, but some of the formulas are quite dense so I like to try writing them out in code.
The chapter I’m currently reading (chapter 9) has a section on finding possibly-uninitialized variables using bipartite graphs. I’ve found it to be quite enjoyable, so in this post I would like to describe, in my own words, how to do it. We’ll start by analyzing some code from a toy programming language the book uses called “TIP”, and then we’ll use the techniques we developed to analyze some Ruby code.
In other words, we’re going to write a program to analyze programs. We’re really going to be meta-programmers today!
Uninitialized variables in TIP
The TIP programming language is like a very very simplified version of JavaScript. Below is the code we’ll be analyzing:
var a, b, c; // a, b, c
a = input; // b, c
b = a + c; // b, c
if (input) { // b, c
c = 1; // b
} // b, c
This example is taken straight from the chapter.
In TIP, you declare variables with the var keyword kind of like in JavaScript.
In chapter 9 of the book, the TIP language only has integers; later chapters add pointers.
In TIP, 0 is considered false, and there is a special function input that returns an integer input from the user.
Each line in the above code has a comment indicating which variables are possibly uninitialized. I keep saying “possibly”, and I’ll explain why later.
The first line declares the variables with var, and all variables start as “uninitialized” when they are declared.
The second line gets input and assigns it to a, so now only b and c are uninitialized.
The third line adds a and c and assigns it to b.
On this line, b is still considered to be “uninitialized” because c is uninitialized, and the value of b depends on the values of a and c.
The fourth line is an if statement, and whether we take the if statement depends on input from the user.
In other words, we can’t statically determine whether this statement is taken or not.
The if statement doesn’t impact the “initializedness” of any variables, so all variables maintain their state (b and c are uninitialized).
If we take the branch, the 5th line will assign a value to c, so at that line only b is uninitialized.
On the 6th line, we can’t know whether the if statement branch executed, so we have to say that b and c are possibly uninitialized.
This is why our analysis is only giving us a “possibly uninitialized” result.
If the branch is taken, then c will be initialized, otherwise it won’t.
But since we can’t know whether the branch will be taken, we have to be conservative and say that c may be uninitialized on line 6.
These two states, initialized and possibly uninitialized, form a two element lattice, with possibly uninitialized on top.
The top is the “safe” answer: it’s never wrong to say a variable might be uninitialized, it’s just less precise.
When two paths in the code meet, like at the end of the if statement on line 6, we take the higher of the two states (because the less precise decision is also the “safe” one).
On one path c is initialized, on the other it’s possibly uninitialized, so after the if statement it’s possibly uninitialized.
Representing data flow with a graph
The rules for each line in uninitialized variable analysis are so-called “distributive functions”.
For example, take the rule for line 3, b = a + c.
If we feed it the set {a, c}, we get the same answer as feeding it {a} and {c} separately and then taking the union of the results: rule({a, c}) = rule({a}) | rule({c}).
This is just like in math class where we could say a(b + c) = ab + ac.
In the case of a two element lattice and an analysis that uses a distributive function, we can represent the variable states using a bipartite graph. The book works out the proof for this, but I’m going to omit it (because I want to get to the coding part). But if you want to see the proof, check chapter 9!
What is a bipartite graph?
A bipartite graph is just a graph with “two sides”. Nodes on one side of the graph will connect with nodes on the other side of the graph, but never with their own side. I imagine it like two football teams tackling each other, each player is a node, and tackling someone else is an edge in the graph. Hopefully the players don’t tackle people on their own team, and they form a bipartite tackle graph!
In our case, each node in our graph will represent a variable in the program, and we’ll have one extra node that represents “uninitialized”. For one graph, we’ll double those nodes, and use the edges to represent the flow of data through the code.
For example, let’s look at the bipartite graph for the first line, var a, b, c:
The * node on the left represents the value “uninitialized”.
On the var a, b, c line, the “uninitialized” value flows from * to the a, b, and c nodes.
Next let’s look at the graph for line 2, a = input;:
In this graph, the uninitialized node flows to itself.
The variables b and c haven’t changed, so whatever value was in b and c just flow to the other side of the graph.
The variable a got initialized from input, so no value flows to the other side of the graph.
The reason no value flows is because we’re tracking “uninitializedness”, and on this line a becomes initialized, so the absence of an edge means we got a value.
If we repeat this value flow process and then line each graph up next to each other, we’ll end up with a graph like this:
A black dot in the variable’s column indicates that the variable is possibly uninitialized at that point. Grey dots and lines indicate paths that don’t occur because the link was severed earlier. The dotted lines indicate alternate paths the code can take; an if statement has two outgoing paths.
Looking at the graph we can see that (obviously) uninitialized (*) is always uninitialized (we don’t bother to print that out because it’s not very helpful).
We can also see that the black dots correspond to the “uninitialized variable” comments from the example code.
For example, right after b = a + c, the b and c variable have a black dot indicating that they are possibly uninitialized.
Representing a bipartite graph in Ruby
If we assign each variable a number (and reserve 0 as the number for “uninitialized”), we can represent this bipartite graph in Ruby using a bit matrix.
The graph needs to store a connection between any of the variables, and an “on” bit represents an “uninitialized” state from our lattice flowing from one variable to another.
Since we need to be able to record an edge from any variable to any variable (plus the uninitialized value), the number of bits required is (number of variables + 1) ** 2.
Our example case has 3 variables, plus the extra variable for “uninitialized”, so we can do it in 16 bits. In practice though, to make accessing the data easier, we’ll align on a byte:
class Bipartite
def initialize u, v
# We need v bits times u. Each row (u1, u2, etc) needs v bits
# to represent the edge from u to v.
# We'll round up v to make the buffer aligned
@row_bytes = (v + 7) / 8 # round up to whole bytes
@buffer = "\0".b * (u * @row_bytes)
end
# Set an edge from u to v
def set u, v
# calculate our byte index
idx = u * @row_bytes + v / 8
# set the bit
@buffer.setbyte(idx, @buffer.getbyte(idx) | (1 << (v % 8)))
end
end
The way we use this class is by assigning numbers to variables and then calling set with the edges we need.
For example, to represent var a, b, c;, we need to connect “uninitialized” to the a, b, and c variables.
If we’ve assigned them numbers, we can do it as follows:
STAR = 0
A = 1
B = 2
C = 3
# graph for `var a, b, c;`
line1 = Bipartite.new(4, 4)
line1.set(STAR, STAR)
line1.set(STAR, A)
line1.set(STAR, B)
line1.set(STAR, C)
Now we can represent the data flow rules for any line in the program.
The next step is we need a way to connect graphs together.
To do this, we’ll add another method apply that takes as input a list of “possibly uninitialized variables” (these are also known as “facts”) and returns a new list of “possibly uninitialized variables” based on the data flow rules for that graph.
Since the input variables are just numbers, we can use each one as a row index into our bit matrix. Practically speaking, this means we bitwise OR together the rows for each input variable, and then get the position of the “on” bits in the output.
Here is the code for the apply function:
class Bipartite
def apply us
row = "\0".b * @row_bytes
us.each do |u|
start = u * @row_bytes
@row_bytes.times do |b|
row.setbyte(b, row.getbyte(b) | @buffer.getbyte(start + b))
end
end
set_bits row, 0 # get all set bit positions
end
private
def set_bits bytes, start
list = []
@row_bytes.times do |b|
byte = bytes.getbyte(start + b)
while byte.positive?
low = byte & -byte
list.push b * 8 + low.bit_length - 1
byte ^= low
end
end
list
end
end
Let’s add a second rule for a = input;:
# graph for `a = input`
line2 = Bipartite.new(4, 4)
line2.set(STAR, STAR)
line2.set(B, B)
line2.set(C, C)
In the above rule, just as in the visualization above, we connect the uninitialized nodes, then we connect b to itself and c to itself.
These “self” connections mean “whatever value was in b as input, we’re going to pass that along as output”.
Now we can use the apply method and chain the two graphs together:
p line1.apply([STAR]) # => [0, 1, 2, 3]
p line2.apply(line1.apply([STAR])) # => [0, 2, 3]
At the start of the program nothing has been declared yet, so the only fact is “uninitialized” itself (STAR).
If we apply that to the first line, we see that all of the variables are “possibly uninitialized” (as expected).
We can take the output of the application and feed it as the input to the next graph, and we see that the uninitialized variables are 2 and 3 (or b, and c).
Now all we need to do is define the rules for each line of code and apply them, and we’ll be able to calculate which variables are possibly uninitialized!
Defining rules for our program
Next we’re going to create a class that lets us record rules for each part of the program. The example code we wrote above works well for straight-line code since we can just apply the data flow rules from each line to the previous line. But the example program we have contains control flow and we’ll need a way to model the “dotted line” from the visualization above.
To do that, we’re going to construct a simple control flow graph. A control flow graph (or CFG), is constructed from “basic blocks” where each block contains “straight line code” and ends in either a jump or a return. Each block has up to two outgoing edges, and those edges connect to possible jump targets.
There are other ways to define a basic block, but this is my blog, so my rules!
First we’ll define a Block class and a Line class.
The Line class just holds on to the source line (a string for printing), and a graph representing the rules at that line.
The Block class holds the list of Line objects that make up the block.
It also contains a list of successors (jump targets) as well as predecessors (basic blocks whose jump target is the current block):
class Line
attr_reader :source, :graph
def initialize source, graph
@source = source
@graph = graph
end
end
class Block
attr_reader :code, :succ, :preds
def initialize code
@code = code
@succ = []
@preds = []
end
end
Next we’ll define a Program class that has a bunch of helper methods that let us construct a CFG along with a graph for each line.
I’m not going to explain all of the lines of the Program class because I think using the class is a better way to explain it.
However, the full source is below.
Show the Program class
class Program
attr_reader :root
def initialize vars
@vars = [:*] + vars
@index = @vars.each_with_index.to_h # name => bit
@root = Block.new([])
@current = @root
@depth = 0
end
def po; po_traversal(root, Set.new, []); end
def rpo; po.reverse; end
def po_traversal block, visited, post_order
return post_order if visited.include?(block)
visited.add(block)
block.succ.each do |successor|
po_traversal(successor, visited, post_order)
end
post_order << block
post_order
end
# Return a list of effects where each variable just transfers whatever
# was there. A type of "identity" effect
def identity
@vars.map { |v| [v, v] }
end
# Record the effects for a statement in code
def record code, effects
@current.code << Line.new(code.empty? ? code : " " * @depth + code, graph(effects))
end
# Record an if statement: the test, then a block for each arm, and a
# join block where the arms meet. With no else, the test jumps
# straight to the join.
def record_if code, effects, truthy, falsy = nil
record "#{code} {", effects
current, join = @current, Block.new([])
t = arm(join, truthy)
f = falsy ? arm(join, falsy, "} else {") : join
current.succ.push f, t # falsy first, so rpo visits truthy first
@current = join
record "}", identity
end
# Walk the CFG and assign predecessors
def finish
w = rpo
w.each { |b| b.succ.each { |c| c.preds << b } }
w
end
# The fact for the "start" block. It's the index of the uninitialized variable "*"
def start
[0]
end
# Map name indexes back to human readable names
def names xs; xs.map { @vars[it] } - [:*]; end
private
# Record body into a new block (one level deeper) that falls through to join
def arm join, body, label = nil
first = @current = Block.new([])
record label, identity if label
@depth += 1
body.call
@depth -= 1
@current.succ << join # the arm falls through to the join
first
end
# Build a graph of data flow
def graph effects
graph = Bipartite.new(@vars.length, @vars.length)
graph.set(0, 0) # * always holds
effects.each { |from, to| graph.set @index[from], @index[to] }
graph
end
end
Let’s use the Program class to define all of the data flow rules for the sample TIP program:
# Create a new program with the variables a, b, and c
prog = Program.new([:a, :b, :c])
prog.record("var a, b, c;", [
[:*, :a], # uninitialized flows in to a
[:*, :b], # uninitialized flows in to b
[:*, :c], # uninitialized flows in to c
])
prog.record("a = input;", [
[:b, :b], # b is unchanged, so its previous value flows to itself
[:c, :c], # same for c
# a gets an assignment here, so it has no record in the graph
])
prog.record("b = a + c;", [
[:a, :a], # a is unchanged, so its previous value flows to itself
[:c, :c], # same for c
[:a, :b], # the value of a flows in to b
[:c, :b], # the value of c also flows in to b
])
# The if statement doesn't have any impact on "undefinedness" of a variable,
# so we pass the "identity" effect.
prog.record_if("if (input)", prog.identity, -> {
# Encode the true arm of the if statement
prog.record("c = 1;", [
[:a, :a], # a is unchanged, so its previous value flows to itself
[:b, :b], # same for b
# c gets an assignment here, so it has no record in the graph
])
})
# Calculate the predecessors for each block. We need to know the predecessors
# because the output of a block's predecessors is the input to the block we're
# processing
prog.finish
First we define a new program class. Then we ask the program class to record a source line of TIP along with the data flow rules for that line of code.
I added lots of comments to explain the code, but let’s revisit one excerpt just to make double sure:
prog.record("var a, b, c;", [
[:*, :a], # uninitialized flows in to a
[:*, :b], # uninitialized flows in to b
[:*, :c], # uninitialized flows in to c
])
Here we’re asking the program to record the TIP source code var a, b, c;, and the data flow rule for this line is that “uninitialized” flows into a, b, and c.
This is exactly the same as the bipartite graph we created above, but this time we’re just recording that graph alongside the TIP source code.
After we’ve recorded all of the rules, we call finish on the program so that it can calculate and set predecessor edges.
Analyzing our graphs
We’ve got multiple graphs going on here. We’ve got a control flow graph that represents the program overall, and a bipartite graph representing data flow at each line.
Our goal is to walk the CFG, and for each block calculate variable definedness at each line. The input state for each block is the union of the output state for that block’s predecessors.
In other words, in order to analyze the whole CFG, we only really need to record the final state at the end of each block.
Also, each calculation cares what happened in the past. For example, the definedness of the variables at line 2 care about what happened at line 1. This means we have a forward dataflow analysis. Any time the facts we derive depend on what happened in the past, it means we need to move forward through the code.
So we define a ForwardAnalysis strategy class that lets us analyze the CFG by moving forward through the code:
class ForwardAnalysis
attr_reader :out
def initialize prog
@prog = prog
@out = {}
end
# Get a list of blocks we should process.
def blocks
@prog.rpo
end
# Return the input facts for a block.
def input_for block
if block.equal? @prog.root
@prog.start
else
# get the _output_ from our predecessors and OR them together
block.preds.map { |b| @out[b] || [] }.reduce(:|).sort
end
end
# Record facts for block based on input to the block.
# Returns true if we learned something new, otherwise false
def record_output block, input
facts = block.code.inject(input) do |f, line|
line.graph.apply(f)
end
if facts == @out[block]
false
else
@out[block] = facts
true
end
end
# Get the next blocks we should process given an input block.
# For forward analysis, we want to look at the block's successors
def children_for block
block.succ
end
end
In the above class, the input_for method takes a basic block, looks up the predecessors for the block, then unions the output of those blocks.
The record_output function takes a block and some input (which happens to be the return value of input_for), and for each line of code in that block computes output facts based on the input.
Those output facts are the “possibly uninitialized variables” at that line.
Since we really only care about the state of the world at the end of the block, we just use an inject (a left fold for scheme people) over the code in the block.
If, after analyzing all lines in the block, the facts are the same as what we had recorded previously, record_output will return false.
Otherwise, it saves the facts for that block and returns true.
The blocks method returns all blocks in reverse post-order (the traditional order for doing forward analysis that my grandparents used), and the children_for method returns the successors for a block.
We’ve got our CFG, and we’ve got a strategy for iterating the graph and analyzing it. Now we need to actually do the analysis.
We discussed earlier that this problem is a two element lattice with a monotonic function (it moves up the lattice, never down). That means it’s perfect for a worklist algorithm. Our worklist algorithm is actually quite simple, it’s just this:
def process strategy
worklist = strategy.blocks
until worklist.empty?
block = worklist.shift
# Get the input facts for a block
block_input = strategy.input_for block
# Record output facts. If something changed, we have more work
if strategy.record_output(block, block_input)
# Something changed, so walk the block's children
strategy.children_for(block).each do |child|
worklist << child unless worklist.include?(child)
end
end
end
end
analysis = ForwardAnalysis.new(prog)
process analysis
Given an iteration strategy, ask for a list of blocks to process.
While we have blocks to process, get a block.
Ask the strategy for the input facts for that block, then ask the strategy to record output facts for that block given the input.
If we learned something new (record_output returns true), then ask the strategy for children of that block and add the children to the worklist (if they aren’t in the worklist already).
After this processing finishes, the @out hash inside the ForwardAnalysis object will contain possibly-uninitialized variable information for each block.
We can iterate over the blocks in the analysis and print out the possibly uninitialized variables for each line:
def show prog, code, facts
print code.ljust(14)
print " // "
puts prog.names(facts).join(", ")
end
def print_prog prog, analysis
analysis.blocks.each do |block|
# Get the input for this block
facts = analysis.input_for(block)
# Process each line of code and print out the results
block.code.each do |line|
facts = line.graph.apply(facts)
show prog, line.source, facts unless line.source.empty?
end
end
end
print_prog prog, analysis
Since we only recorded output information for each block, we have to do some extra processing at the end to print out the facts at each line, but if we run this code we finally get this output:
var a, b, c; // a, b, c
a = input; // b, c
b = a + c; // b, c
if (input) { // b, c
c = 1; // b
} // b, c
Analyzing Ruby code
Whew. That was a ton of work. But the good news is that we can reuse this framework for other languages too.
I’m not going to explain the intricate details of this code, but we can use Prism to parse Ruby and feed that into our Program class.
The body function walks the Prism AST and records a rule for each statement, using the same kinds of rules we wrote by hand for TIP.
Just to emphasize this, we wrote all of the data flow rules by hand for TIP, but our Prism code does it automatically based on assignments in the Ruby code.
Show the Prism code
def analyze prog, vars, node
keep = vars.map { [it, it] } # every variable keeps its status
case node
when Prism::LocalVariableWriteNode
x = node.name
effects = keep - [[x, x]]
if node.value.is_a?(Prism::NilNode)
effects << [:*, x]
else
(reads(node.value) & vars).each { effects << [it, x] }
end
prog.record node.slice, effects
when Prism::IfNode
prog.record_if "if #{node.predicate.slice}", keep,
-> { body prog, vars, node.statements },
node.subsequent && -> { body prog, vars, node.subsequent.statements }
else
prog.record node.slice, keep
end
end
def body prog, vars, stmts
return prog.record("", vars.map { [it, it] }) unless stmts
stmts.body.each { analyze prog, vars, it }
end
def reads node
return [node.name] if node.is_a?(Prism::LocalVariableReadNode)
return [node.name] if node.is_a?(Prism::CallNode) && node.variable_call? # read before assigned
node.compact_child_nodes.flat_map { reads it }
end
source = <<~RUBY
a = gets
b = a + c
if gets
c = 1
end
puts c
RUBY
program = Prism.parse(source).value
prog = Program.new(program.locals)
prog.record "", program.locals.map { [:*, it] } # every local starts out nil
body prog, program.locals, program.statements
prog.finish
analysis = ForwardAnalysis.new(prog)
process analysis
print_prog prog, analysis
Once we get this into the program class, we can perform the same analysis on this code:
a = gets // b, c
b = a + c // b, c
if gets { // b, c
c = 1 // b
} // b, c
puts c // b, c
(The curly braces are just how our Program class prints if statements, even for Ruby.)
If you actually run this Ruby code, b = a + c will raise a NameError, because c hasn’t been assigned yet so Ruby thinks it’s a method call.
That’s exactly the kind of bug this analysis is meant to catch!
You can find the full source for this program here.
So what?
I feel like finding uninitialized variables isn’t that impressive. But the power in the technique here is that we can do any kind of analysis using this framework as long as the facts we track are a finite set, and the transfer functions are distributive (as I mentioned earlier). (The book calls this framework the IFDS framework, though I’ve omitted part of the full framework in this post).
For example, we could use this same code to do “taint” analysis: “does this variable hold untrusted data?”
If we were to number expressions and perform a backward analysis, we could discover what the book calls “very busy expressions”. A “very busy expression” is an expression that will definitely be evaluated no matter which path the code takes, before any of its variables change.
For example:
def total price, quantity, member
if member
puts "Member price: #{price * quantity * 0.9}"
else
puts "Price: #{price * quantity}"
end
end
In the above example price * quantity is considered “very busy” because we calculate it regardless of which branch the if statement takes.
That means it is a candidate for hoisting above the if statement.
Anyway, I’m having a lot of fun reading this book and I just wanted to share the stuff I’ve learned so far.
Have a good day!


