You Need To Understand Functions

This is for developers who, like me, didn't understand functions for a long time, at all. For too long they were just some sort of magic formula to me. I needed to put values in between the parentheses and (sometimes, probably) do something with a return value. But I had no idea what to put there, what code I was supposed to write in a function definition, what to do with return values, and couldn't find much of a good explanation.

For a long time the industry has defaulted to pointing at mathematical functions and insisting that they're similar, and ending the explanation there. But I didn't learn about functions in maths until well after I started using them in code. If you haven't had an education in maths and computer science before, chances are, you need a proper explanation of what a function is. And if you have, chances are, you'd still benefit from one, since functions in code are not really the same as math functions at all.

What is a Math Function

To start with, we should get the definition of a math function out of the way. It comes up often in theoretical discussions, and it does shape the way functions are used in a lot of languages.

In maths, a function takes an input, and applies a formula to that input, returning the result of evaluating the subsequent equation. Let's make that concrete for a second. We'll take a really simple function, let's say we just want to double a value. We'll define it like this: f(x) = x + x. (Apologies to any math nerds who might be bothered by me using code tags to format math equations, I don't have a LaTeX library in my frontend pages and I'm not going to track one down for a couple of short equations.)

This is an abstract definition. When we pass in a concrete value, let's say 2, we get f(2) = 2 + 2 = 4. f is the name of the function, x is the parameter, 2 is the argument in this case, and 4 is the return value. At least that's what they'd be called if we applied the conventional terms from programming languages.

Mathematicians don't have to deal with questions of side-effects, primarily of reading and writing to disks and networks, like programmers do. So they can define functions in clean terms of inputs to, and outputs from, functions, without having to consider the possibility of values changing along the way. (If you've read my article on environments, you know how values can get changed outside of functions.)

So this is a clean understanding of how a function is defined in maths. Now how about functions in programming languages?

How Functions Work on a Computer

The only way to really understand how computers process a function is to look at how it's handled in Assembly. If you've ever wondered about Assembly but been a bit too scared to look at it or try to understand, good news! Today we will be looking at some, and I promise, it won't be too scary. Assembly is tedious to write and tedious to read and follow, but it's not that hard to understand. You just have to be very patient with it. If you find this section too confusing, you can just skip it and come to the following section, where I explain what it all means at a high level. Come back and read this section when you are feeling more confident about the concepts.

Before I get into it, I will give a brief overview of the point this section will be making. In order to pass an argument to a function, the calling function needs to place a value in a specific part of memory, then tell the computer's program pointer to jump to the part of code where that function starts. The computer also saves the spot where it was when it was called in another special place. The program jumps to that point, reads the values of the arguments, and performs calculations with them. Then it writes the return value to another special part of memory, and allows the computer to jump back to where it was when it was called. The calling function then reads the return value from the part of memory it was saved in, and continues where it left off. As you can see, very different from the high level concept you find in mathematical functions.

High level languages, and in this context even C counts as a high level language, express actions in ways that make relative sense to a human mind. But they are not the way that math operations get expressed to a chip that can only express very basic flows of boolean logic in the form of electrical circuits. For that, you need Assembly. It's the closest human-readable form to machine language.

So let's start with an example program in C. I have kept it really minimal on purpose, so that the generated Assembly will be small. And I've built it up slowly. That way, you don't need to understand the ARM assembly used on my laptop, you can just compare three different source files and see how they've changed.

The Most Basic Example

So to start with, we have the most minimal possible C program:


int main() {
  return 0;
}
	

This just creates the main function required as the entry point for any executable C program (as opposed to a library, which can get included in other executable programs).

"main" is a specific symbol that the computer looks for when running a program, so it knows where to begin. And by "where to begin", I mean, where the program pointer points to. That's a special value that the CPU of a computer keeps track of to know where it's up to when it's running a program.

To see this basic program in Assembly, assuming you are using a Linux or Mac laptop, and you already have the tools installed to run a C program, you run gcc -S program.c. The -S flag tells the compiler that we want the Assembly file that it normally generates and throws away. Once you've run this, you should see a program.s file in your working directory. It will look like this:


  .section	__TEXT,__text,regular,pure_instructions
  .build_version macos, 15, 0	sdk_version 15, 5
  .globl	_main        ; -- Begin function main
  .p2align	2
_main:                       ; @main
  .cfi_startproc
; %bb.0:
  sub	sp, sp, #16
  .cfi_def_cfa_offset 16
  mov	w0, #0               ; =0x0
  str	wzr, [sp, #12]
  add	sp, sp, #16
  ret
  .cfi_endproc
                             ; -- End function
.subsections_via_symbols
	

This probably looks like a lot of horrible noise right now, but if you have a look closely, you can start to make some sense of it. (If you do actually want to dig into the specifics of Assembly on a 64-bit ARM Mac laptop, this looks like a great resource for that.) The first few lines are just some metadata. We tell the processor what type of data to expect in this section of the file, give it the language version, tell it we are declaring a function (I assume, without having checked, that .globl refers to scope), and tell it about alignment (probably alignment of word size, so 2 bytes in this case, which would match the 16s dotted through the file for offsets).

Then we start on the function itself. We declare that the function has started. We then move the stack pointer (a pointer to a part of memory that this function is allowed to access). Here's what I do want you to take away from it. Notice this line: mov w0, #0. w0 is the name of a register (a specific cluster of circuits on the CPU that can store a value for quick access), which holds return values. (I know this because I had a look at this website which shows that the register values from 0 to 7 are used for return values (it also indicates that this Assembly has been compiled for 32 bit architecture, presumably as a way my Mac has of saving space). Ideally I would have a look at Apple's official documentation for ARM, but it's huge and I couldn't find an easy reference for register numbers. For a small example like this, where it's handled by the compiler and not me, a reference like this one is adequate.) We are storing the number 0 in that return register. If you look at the C code this came from, this is just the Assembly for returning 0.

Adding a Function Definition

Now that we have seen the most basic example, we can have a look at how the file changes when we add a function definition to the file. Here's the C code:


int double_num(int x) {
  return x + x;
}

int main() {
  return 0;
}
	

I haven't called the function yet. I'll do that in the next example, to avoid overcomplicating this one. Here's the Assembly:


  .section	__TEXT,__text,regular,pure_instructions
  .build_version macos, 15, 0	sdk_version 15, 5
  .globl	_double_num  ; -- Begin function double_num
  .p2align	2
_double_num:                 ; @double_num
  .cfi_startproc
; %bb.0:
  sub	sp, sp, #16
  .cfi_def_cfa_offset 16
  str	w0, [sp, #12]
  ldr	w8, [sp, #12]
  ldr	w9, [sp, #12]
  add	w0, w8, w9
  add	sp, sp, #16
  ret
  .cfi_endproc
		              ; -- End function
  .globl	_main         ; -- Begin function main
  .p2align	2
_main:                        ; @main
  .cfi_startproc
; %bb.0:
  sub	sp, sp, #16
  .cfi_def_cfa_offset 16
  mov	w0, #0                ; =0x0
  str	wzr, [sp, #12]
  add	sp, sp, #16
  ret
  .cfi_endproc
		              ; -- End function
.subsections_via_symbols
	

Now this program looks more complicated and daunting than the last one. But again, if you compare them closely, you will see that there is only one major change. In fact, I encourage you to copy the code from these two into a couple of text editor windows on a large monitor and directly compare them.

You will see that most of the initial lines are the same, except that we are defining _double_num before the main function now. (As a side note, I think the single underscore prefix is used for all user-defined functions in Assembly, as that prevents any conflicts with Assembly-specific reserved keywords. That way you can have a function called add for example, and it will compile to _add, without clashing with the Assembly function of the same name).

Take a look at the two lines in the _double_num function which start with ldr. That's for loading values from the stack into registers. Notice that the stack pointer points to the same place in each case, because we are adding the same value twice. But we want it in two different registers for adding, as Assembly requires the values of two different registers. At least, that's my inference from reading this code anyway. If we instead create a version where the function receives two different arguments, we see the same Assembly output, except that the stack pointer moves, so we get:


  ldr w8, [sp, #12]
  ldr w9, [sp, #8]
	

Once we have loaded the arguments into the registers, we add them, and store the value in the return register: add w0, w8, w9. Then we move the stack pointer back to where it was before we started the function: add sp, sp, #16, and jump back to the return location: ret.

Calling a Function

Now, one more example to bring it all together:


int double_num(int x) {
  return x + x;
}

int main() {
  return double_num(0);
}
	

This time we're calling the function from the main function. I'm passing it 0, because that way we can return 0 to the shell, otherwise this program would be effectively signalling an error. (There's nothing really wrong with that per se, but I chose to stick with 0. This whole program is a toy example anyway, you would never return a programatically calculated value from another function from main anyway, but I'm keeping this really small to make it readable.)

Let's see what that looks like in Assembly:


  .section	__TEXT,__text,regular,pure_instructions
  .build_version macos, 15, 0	sdk_version 15, 5
  .globl	_double_num   ; -- Begin function double_num
  .p2align	2
_double_num:                  ; @double_num
  .cfi_startproc
; %bb.0:
  sub	sp, sp, #16
  .cfi_def_cfa_offset 16
  str	w0, [sp, #12]
  ldr	w8, [sp, #12]
  ldr	w9, [sp, #12]
  add	w0, w8, w9
  add	sp, sp, #16
  ret
  .cfi_endproc
                              ; -- End function
.globl	_main                 ; -- Begin function main
.p2align	2
_main:                        ; @main
  .cfi_startproc
; %bb.0:
  sub	sp, sp, #32
  stp	x29, x30, [sp, #16]   ; 16-byte Folded Spill
  add	x29, sp, #16
  .cfi_def_cfa w29, 16
  .cfi_offset w30, -8
  .cfi_offset w29, -16
  mov	w0, #0                ; =0x0
  stur	wzr, [x29, #-4]
  bl	_double_num
  ldp	x29, x30, [sp, #16]   ; 16-byte Folded Reload
  add	sp, sp, #32
  ret
  .cfi_endproc
                              ; -- End function
.subsections_via_symbols
	

The code for _main is now a fair bit more complex. It also appears that the compiler has optimised some of this. You can see that it moves the value 0 into the register w0, which is commonly one of the registers used for passing arguments to a function as well as storing return values. Since that value is the same as the return value, it then never moves a value into w0 again afterwards.

What's more interesting is the bl instruction, which stands for branch and link. We are telling the program to unconditionally branch, or jump to another location (as opposed to conditionally branching, as it would in an if-clause, where it only branches based on the outcome of comparing a value). We also tell it to link the current code location, so that it knows to jump back to this location once the previous code has executed (which it knows from the ret and .cfi_endproc lines at the end of the called function).

All this is an imperfect explanation of how functions work in Assembly. If you want to go into a lot more detail, I recommend this repo, and this youtube playlist. LaurieWired has an amazing channel, her videos look really well done. I'd recommend having a look.

It's possible that my particular code example actually makes this topic slightly more complicated to understand, due to the way it uses a return value from one function as the return value for another. I've left it in this way so that you can see how I was able to experimentally build up an understanding of the Assembly largely just from observation and seeing what changed. This is a good habit to get into as a student of programming, as it is a very effective way to learn.

Now, let's get into what this means for understanding functions.

Assembly vs Mathematics

What I want you to take away from that explanation of how functions work in Assembly is how fundamentally different it is from the mathematical model of functions. The way a function works in maths, and probably in your own head if you haven't thought about implementation much, is that a function is like a box. You pop a value into it when you call it, it does some processing, and a (probably) different value gets popped out the other end. It's fairly clean and tamper-proof, and it always does what you expect.

On a computer, what you are doing is quite different. This is where the description I gave at the beginning of the last section should start to make sense. You move values into registers. Then you save the spot in the code you are up to. You jump to the function label. You move the stack pointer, which gives you access to your own memory for the function, and may also contain values that didn't fit in the registers, and check the register values for your arguments. You perform a computation, then you store a return value back in a register, and jump back to where you were in the code before.

Functional Programming

This has several important implications. First of all, a void function makes perfect sense in Assembly. In maths, you can't have a function that takes no arguments, returns no values, but still does something interesting along the way. In Assembly, you can. A lot of people don't like that fact, but unless you work in a language like Haskell that makes it impossible, there's nothing stopping you from doing it.

Secondly, there's nothing stopping you from changing the values that you were given directly. In fact, it's often more efficient to do this. There's also nothing stopping you from changing other values, ones that are stored globally, while you are running your function. A lot of these actions are not limited to Assembly. You can do them in C, Python, JavaScript, Java and C++ if the values are exposed and in the right scope. This can lead to some simple and convenient shortcuts, and it can lead to some complicated and bug-ridden codebases. There are some big tradeoffs involved. Either way, you can see that this is a long way from the mental model you might have had of functions before.

Functional programming, which is a paradigm, or style of programming, that advocates for using pure, math-like functions in code, has an answer for this. They tend to have ways of enforcing objects that don't allow mutation. So when a function receives a reference to an argument, it can only make a new object, which is calculated using the old object. It can't change the value that the old reference points to.

They also tend to isolate side-effects, in other words, tampering with outside systems, writing to disk, making network calls and so on, to special functions. This ensures that those tasks only occur in specific parts of the codebase, making the rest of it easier to reason about.

A lot of people argue that languages like Haskell go too far, especially as they tend to be much slower than languages like C. But there is a strong argument to be made that isolating I/O to specific parts of the codebase, and being explicit when objects are getting mutated, are good practices for keeping a codebase maintainable.

Functional programming, where functions can be passed around as data, leads to some very exciting ways of writing code. Using lambdas, a lot of recursion, mapping operations onto large groups of data, these are all much easier in functional languages. Some of those things are technically possible even in C, but they are not fun to write, and they require some mangling to go against the grain of the language.

Security

Another big implication of the way functions work in Assembly is the security of it. If the arguments to a function, and the addresses of functions to execute, are just values in memory, what if you could change those values? As an outside attacker, if you could change the return address that the code goes to after finishing a function call, you could get it to execute whatever you want. This is frequently done by passing such a big value into the space for a function argument, that it spills over and fills up the return address part of memory too. This is a really common type of attack, known as a buffer overflow.

If you don't work directly in C, you probably don't often have to directly write code that checks the size of user input, ensures that it's null-terminated, throws errors when it's not. But you probably will see all sorts of overflow issues mentioned in the CVEs that are reported against the language or technology you are using. It's still a big problem in a lot of software. That's why people try to encourage the use of languages like Rust, that make it harder to make mistakes with memory management.

Now that you've seen the unstructured reality of functions in Assembly, you can understand why these errors are common. There's nothing inherently discrete and secure about a function. We build up safer abstractions on top of it in higher level languages, that are supposed to be easier to reason about and harder to get wrong, that stop users from having access to addresses in memory. But underneath, it all compiles down to the same type of error-prone machine code. And you will still find those quirks of the machine bubbling up in high level languages like Python, where you can change values without meaning to. Here's an example:


>>> def modify_array(arr: list[str]) -> None:
>>>	if len(arr) < 1:
>>> 		return
>>> 	arr[0] = "modified"
>>> a = ["hello", "world"]
>>> modify_array(a)
>>> a
['modified', 'world']
>>>
	

As you can see, I changed the value of the original argument that was passed in. No new array was created. This is common and easy to do in many languages, and frequently leads to dangerous code. This essay isn't an argument for using only functional programming, but it is important to be aware of what your language can really do, and to avoid the pitfalls where possible.

Why Is All This Important?

The implications of this information for cybersecurity are obvious, but they're not my main reason for writing this. You can find good cybersecurity advice in plenty of places if you read high-quality blogs about low level programming.

The main reason I wrote this is because I think understanding this information can give you a better mental model of what the computer is doing, which makes it easier for you to understand the code you are reading. If you've read my article on environments, you already know how scopes get created for functions when they are called.

Now you know what that looks like in specific terms on the computer. You call a function, the values that should be visible to that function get added to the stack, where the function is able to view the values in its environment, then the function returns, the values get popped off the stack, and the return values are made visible to the scope of the outer program. Exactly how all this is handled is often language-dependent, but understanding that that is what is happening will strengthen your mental model.

This also allows you to understand stack traces. When your program fails, and an exception gets thrown all the way up to the top, what you will see in languages like Python and Java, is a list of stacks, the stacks that were visible to the functions that were called. That's why it's called a stack trace.

This is also what you are seeing when you run a debugger. Every time you step into a function call, the visible values update. You can view what the function can see, every step of the way. The mental model of environments, and the mental model of functions on the stack using registers, start to blur here. They both contribute to your understanding of what the computer is doing.

This also explains why recursion hits a limit in languages that don't optimise for it (using what's called tail call recursion). Every time you recurse into a function, you create a new stack frame, and if you have too many of those, you run out of room on the stack, you start "stack smashing", accessing memory that doesn't belong to you, and your program gets killed by the kernel.

This mental model of functions also explains how function pointers work. A function pointer points to a place in memory where the executable code for a function is stored. When you dereference that pointer, you get the code, which you can then ask the runtime to execute. That's a big part of the secret allowing functions to be used as data, which happens in a lot of high level languages now. It's key to JavaScript's callback system. But it was first taken advantage of by Lisp.

Conclusion

You should now be able to clearly see the difference between functions in maths and functions in code. We can build a system on top of the code that allows us to write code as if it worked like functions in maths, but underneath, it's all just labels and registers. The compiler doesn't know the difference.

When you combine this understanding with the mental model given by environments, you should start to have a good sense of what code is really doing. If you don't understand it yet, that's normal. These ideas are hard to grasp, even if they are simple at their core. Go away and write some code, and come back to read this article again in a week or two and see if it makes more sense.

In the next article, I'll be explaining classes, which build on the knowledge you've gained from this one and the one on environments. If you've ever struggled to understand what a class really is, the next article should help you with that.