#StandWithUkraine

Russian Aggression Must Stop


Programming fundamentals - Part 03: Logic, branching and functions

2025/08/24

Tags: programming tech programming fundamentals

This is the third chapter in the series on covering programming fundamentals for newcomers to programming and for those refreshing their basics.

This time around we will cover the basics of branching and conditional execution, allowing our programs to complete more complicated tasks than they have up to this point, where our programs have been simply linear sets of instructions.

The turning point

Before computers, there existed various tools to help people make calculations. These ranged from the humble abacus to the mechanical calculators, all allowing basic arithmetic to be given over to a machine instead of having to be mentally calculated.

At its most basic, a computer is only different from these simple calculation machines in one simple way. However, that one small difference unlocks a fairly significant amount of power. What computers are capable of, and an abacus isn't, is logical branching.

A computer can be asked to compute something and then use the result of that computation to determine which action to take next. With a sufficient number of branching cases (and enough time and memory), any computable algorithm can be computed by any computer capable of executing a logical branch.

At a high-level, branches come in two types: conditional statements and loops. Conditionals allow us to determine a subsequent path of execution but still proceed linearly, whereas loops allow us to execute given steps multiple times until some condition is satisfied. It's worth noting that this taxonomy of conditionals and loops is basically just an abstraction and mostly exists only at the high-level. In fact, we will later show how all looping and branching can be done with just one mechanism. Furthermore, in a future chapter in this series we will discuss more about how computers represent conditionals and loops at a lower level.

Boolean logic and if expressions

Lua, like most programming languages, provides a basic tool for branching with the if expression. It takes the following basic form:

if CONDITION then
   -- Execute the BODY1 if CONDITION is true
   BODY1
else 
   -- Otherwise execute BODY2
   BODY2
end

CONDITION in this case can be substituted with any expression that results a value of the type boolean, meaning either true or false. BODY1 and BODY2 are either a single expressions or a series of expresisons which will be executed depending on the result of the CONDITION. In the previous chapter we already saw a simple demonstration of the if expression when we showed that we can test against equality with the == operator:

if 1 + 1 == 2 then
   print("math works")
else
   print("something is wrong")
end
math works

In this example, 1 + 1 = 2= is our CONDITION and our expression bodies are print("math works") and print("something is wrong") respectively.

If we don't want to take any action in case the condition is false, we can simply omit the else branch, leaving just the following form:

if CONDITION then
   BODY
end

Now that is all well and good if we only want to check for a single condition. But what if we want to only take an alternative execution path only if some other condition is satisfied? Well, one way is to simply nest if statements like so, although we will discuss better ways shortly:

if FIRST_CONDITION then
   BODY1
else
   if SECOND_CONDITION then
      BODY2
   else
      -- Both FIRST_CONDITION and SECOND_CONDITION were false
   end
end

This is functionally a perfectly suitable way to make multiple separate branching decisions, but it is a bit verbose, tricky to understand and the increased nesting causes the subsequent if checks to "march to the right", meaning that when our code is properly indented (inner parts are spaced more to the right) the lines begin further and further towards the right.

Lua provides a mechanism to help us here with elseif:

if FIRST_CONDITION then
   BODY1
elseif SECOND_CONDITION then
   BODY2
else
   BODY3
end

This is fully equivalent in meaning to the first example, but easier to understand and less verbose. This kind of a thing is often referred to as "syntactic sugar", meaning it is an abstraction provided by the language for something that you could express otherwise, but with more trouble.

Looping with a condition

We can also run the same bit of code while a condition is met. This brings us to the while expression, which looks like this:

while CONDITION do
   BODY
end

In this case BODY gets repeatedly executed as long as the CONDITION expression remains true. The CONDITION expression is therefore also executed repeatedly. If we put something like 1 + 1 = 2= there or even just true, the loop would never exit and our program would be stuck.

So, we need an expression that can change over time. Luckily for us, we already touched upon the concept of variables, which store values and can be changed. So, let's make a program that counts to 10:

local counter = 1

while counter <= 10 do
   print(counter)
   counter = counter + 1
end
1
2
3
4
5
6
7
8
9
10

In this example, we create the variable counter and assign it our starting value. Then in our loop condition, we check if the value of counter is less than or equal to 10 (since we want to count up to 10, beware of the classic "off-by-one" error). As long as that condition remains true, we will print out the value of the counter and then increment that value by one. If the incrementation looks unfamiliar to you, just remember that in all assignments we simply take the value on the right side of the equals sign and put it into the variable on the left side. Because counter was already defined with a value of 1, counter + 1 produces the value of 2, which is assigned into counter. Then it will produce the value of 3, 4, 5 and so on until we stop looping.

Technically the while loop is the only looping construct we would need and in fact you can even simulate an if expression with it like this:

local keepGoing = true

while 1 + 1 == 2 and keepGoing do
   print("Math works!")
   keepGoing = false
end
Math works!

Our basic conditional is simply the old 1 + 1 = 2= but we have also introduced a variable just to break us out of the loop, since otherwise we would be stuck. We just assign the value of false to that variable when we have executed the loop body once.

However, despite while loops being technically the only thing we need for all conditionals and loops, it would get pretty unwieldy to and unreadable to use it like that. Sometimes it's better to use a tool that is designed for a specific purpose.

In fact, Lua has another loop type similar to while that I personally have never found much use for, but which may be useful in some niche circumstances. This is the repeat loop:

local counter = 1

repeat
   print(counter)
   counter = counter + 1
until counter > 10
1
2
3
4
5
6
7
8
9
10

The repeat-until loop is designed in such a way that the condition is checked only at the end, meaning that the loop body is always executed at least once. In my opinion these types of loops are a somewhat archaic construct left over from the early years of structured programming and don't usually have much use over the more popular while loop. However, sometimes it might be easier to express an idea with repeat instead, so the choice is yours which option you will pick in each situation. My warning to you though is that all repeat-until loops can be expressed with while, but not all while loops can be expressed with repeat-until.

Counted loops

One very typical scenario encountered in a lot of programs is somewhat similar to the counting to 10 example we used with our while and repeat loops, where we have some kind of an incrementing counter or an "index" that we need to perform actions with. We could of course always loop with a while loop, but it's once again pretty verbose and unnecessarily complicated for such a common operation.

So, a sub-category of loops known as "counted loops" was created. In Lua we do a counted loop with the for expression:

for counter = 1, 10 do
   print(counter)
end
1
2
3
4
5
6
7
8
9
10

As you can see, we were able to avoid a separate variable declaration and manual incrementing. Instead we declare the variable where a conditional would normally be declared. The for construct is a bit more complicated and basically looks like this in its full form:

for VAR = INIT_EXPRESSION, TARGET_EXPRESSION, STEP_EXPRESSION do
   BODY
end

The only mandatory parts are the variable declaration and the TARGET_EXPRESSION, the STEP_EXPRESSION is by default assumed to be the value 1 if not provided. With the INIT_EXPRESSION you set the starting value of the variable and the loop will continue to increment the variable by the amount given by STEP_EXPRESSION until the value of the variable reaches the value given by TARGET_EXPRESSION.

The for expression also has another form, but we will cover that in more detail when we talk about lists and tables in a future chapter.

Functions - calling code and returning back

The last thing we will tackle today is a quick trip to the concept of functions, which act as an interesting branching mechanic that often doesn't feel like branching at all. However, under the hood they are implemented with branches, they simply end with a return back to the caller.

We have already used some functions, such as type(), io.read() and tonumber(). Functions work quite similarly to the similarly named mathematical concept: they are bits of code that take arguments and may return a value based on those arguments.

Creating a function for adding two numbers together and calling it is done like this:

function add(a, b)
   return a + b
end

print(add(1, 1))
print(add(2, 2))
2
4

As you can see, functions are created using a function expression, which takes the name of the function followed by the arguments of the function inside parentheses and separated by commas. Functions can take basically any number of arguments, but in most cases only between 0-5 arguments is actually manageable.

A way to conceptualize basic functions like this is to think of them as a way to substitute some code where we are calling the function. This substitution principle will only take us so far, but for now it's good enough for us.

So, we could imagine writing the same code like this:

print(1 + 1)
print(2 + 2)
2
4

In this example, declaring a function isn't very useful at all, we had to write more code to achieve the exact same thing we could have done without functions. However, when we write more complex programs, we often have to create more complicated series of expressions that might be repeated multiple times in our code. That's one reason we reach for the functions.

Functions are also a mechanism to improve readability. They allow us to create named sections of our program, so that when we are trying to understand what the program does, we can first read the series of function calls and then dive deeper into details as necessary.

As an example, the hypothetical source code for a video game might look something like this at the top level:

function gameLoop()
   while gameShouldContinue() do
      local input = getPlayerInput()

      movePlayerCharacter(input)
      moveEnemies()
      checkForCollisions()

      drawLevel()
      drawPlayer()
      drawEnemies()
   end
end

Each of the individual functions could be arbitrarily complex, but by sectioning them off into properly named functions, we can get a decently good understanding of what our game loop is doing without having to deal with unnecessary detail.

Tangent: scopes, locals and globals

In the previous chapter I mentioned that it's preferable for variables to be declared local unless otherwise required. The reason for this is scoping: when we deal with program state, we want it to be clear what state we are referring to and modifying at any given time.

To help us with that, just about every programming language, including Lua, provides us a concept of scopes. Every block of code, that block being a file, a function, a loop or an if expression introduces a new scope within which we can create new variables. At any point we can refer to variables that are within the current scope or any variables that have been declared so far in any of the outer scopes.

To illustrate, let's create the following program with a few levels of scopes.

-- These are variables at the top-level scope, also known as a global variables  
myVariable = "foo"
mySecondGlobalVariable = "bar"

function myFunctionScope()
   -- We can access the top-level variables here
   print(myVariable)

   -- We can also introduce a new variable, even with the same name
   -- Creating a new variable with the same name as a previous one
   -- is known as "shadowing"
   local myVariable = "baz"

   print(myVariable)

   for i = 1, 1 do
      -- The for loop introduces the counter variable "i", which
      -- we can use within the loop body
      -- We can also use the variables from any scope above us here
      print(i, myVariable, mySecondGlobalVariable)
   end

   -- However, the "i" variable does not exist after the loop is complete
   -- as it is out of scope
   print(i)
end

myFunctionScope()

-- Note that the local declaration did not alter the global variable, even though they share the name
print(myVariable)
foo
baz
1	baz	bar
nil
foo

Global state is difficult to deal with, because it becomes hard to track where it is being modified. So, whenever possible, declare your variables as close to where they are needed. If they are needed in multiple places, you should try to pass them as parameters if you are dealing with functions, or you should move them to the lowest common scope in order to avoid complexity.

And remember, declaring a variable isn't the same as modifying a variable. So, for declarations use local, leave it out when modifying a variable.

Putting it all together - Fizz Buzz

One traditional software engineering interview question is a simple children's game called Fizz Buzz. You are given a series of numbers and if the number is divisible by 3 you print out "Fizz", if the number is divisible by 5 you are supposed to print out "Buzz" and if the number is divisible by both, you print out "FizzBuzz". Otherwise the output should just be the number itself.

Let's start out by just handling the first case of divisibility by 3. We can check for divisibility using the modulo operation, meaning that N is divisible by 3 if N modulo 3 is zero, meaning we don't have a remainder after the integer division.

Let's go through the numbers from 1 to 20 and check each of them:

for n = 1, 20 do
   if n % 3 == 0 then
      print("Fizz")
   else
      print(n)
   end
end
1
2
Fizz
4
5
Fizz
7
8
Fizz
10
11
Fizz
13
14
Fizz
16
17
Fizz
19
20

Okay, that satisfies the first case, now let's deal with the buzzes:

for n = 1, 20 do
   if n % 3 == 0 then
      print("Fizz")
   elseif n % 5 == 0 then
      print("Buzz")
   else
      print(n)
   end
end
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
Fizz
16
17
Fizz
19
Buzz

Getting there, but now we need to deal with divisibility by 3 and 5. We cannot just put that as the third case, because only one branch in the if expression will be taken. So, we put it at the front.

for n = 1, 20 do
   if n % 3 == 0 and n % 5 == 0 then
      print("FizzBuzz")
   elseif n % 3 == 0 then
      print("Fizz")
   elseif n % 5 == 0 then
      print("Buzz")
   else
      print(n)
   end
end
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

That is a perfectly reasonable way to do it. Now, let's make it so that our program can take input from the user and not just loop through the numbers sequentially. This means we must switch from the for loop.

userInput = nil

while userInput ~= "q" do
   userInput = io.read()

   local userNumber = tonumber(userInput)

   if userNumber then
      if userNumber % 3 == 0 and userNumber % 5 == 0 then
         print("FizzBuzz")
      elseif userNumber % 3 == 0 then
         print("Fizz")
      elseif userNumber % 5 == 0 then
         print("Buzz")
      else
         print(userNumber)
      end
   end
end

Note that we had to check if the user inputted a valid number after we attempt converting it into a number by checking if userNumber is "truthy". If the user inputs "q" to quit, tonumber() will return a nil which is "falsy". Truthy means a value that can be interpreted as true and falsy a value that can be interpreted as false.

This program looks like this in action:

λ lua conditionals-and-loops.lua 
1
1
3
Fizz
5
Buzz
15
FizzBuzz
8
8
10
Buzz
q

This works okay, but we can probably afford to clean it up a little. Let's put that niche repeat-until loop into use and refactor the fizz-buzz logic into its own function.

function fizzBuzz(n)
   if n % 3 == 0 and n % 5 == 0 then
      print("FizzBuzz")
   elseif n % 3 == 0 then
      print("Fizz")
   elseif n % 5 == 0 then
      print("Buzz")
   else
      print(n)
   end
end

repeat
   local userInput = io.read()

   local userNumber = tonumber(userInput)

   if userNumber then
      fizzBuzz(userNumber)
   end
until userInput == "q"

There we go, I think that has cleaned up the program quite a bit and it's easier to understand what is happening. And by using a repeat loop, we were able to take out the variable initialization and put everything under the loop body, avoiding the global variable.

You are now able to do some pretty complicated programs already, so feel free to build up either this program or possibly create a better version of the number guessing game, now that you have the ability to do loops and branches.

What's next?

We have unlocked almost all of the basic powers of programming we described in the previous chapter, but so far we have only been able to operate on individual pieces of data, such as individual numbers or strings. Next time we learn how to organize data into groups using tables, so that we can operate on larger amounts of data more easily. This will be an important stepping stone on the way to creating more complex representations of data and therefore creating more complex programs.

Hope you enjoyed this chapter and see you in the next one!

>> Home