Photo by Dan Cristian Pădureț on Unsplash
How to think like a programmer: What are variables, really?
Ah, the mighty variable. It's a core tenet of programming. You assign them, you use them, maybe you reassign them. They store your strings and numbers and booleans and other things from time to time, and you don't give them much more thought.
But how well do you know these little pieces of code? What are they up to behind the scenes? What pitfalls lurk in their more esoteric uses? And most importantly, how do you make sure the variables you use will do what you expect them to do?
This post will work through a few if these questions, and is designed to help get you thinking about more than just the variable name, and to inspire you to dig in to your language of choice and gain a deep understanding of the humble variable. This is not designed to be a tutorial on how to use variables, however. There are countless resources out there that do that already, and better than I could do it. I assume you are coming in to this with at least a small intro to a programming language under your belt. Instead, this is designed to help you figure out why you use variables the way you do, or at least get you excited to find out on your own, and to get better at understanding their strengths and limitations. This post came out on the long side, but hopefully you find it entertaining.
So lets get started!
What is a variable in the first place?
This is an interesting question, because it seems so basic. It's not as simple as you might think though.
The official Wikipedia definition of a variable is some pretty dense reading. There is a lot of jargon involved. It's accurate, but can be confusing until you know what everything means. So we'll make this simple, by giving you a few bullet points to remember.
- A variable is a place to store information that you need to use later on in your program.
- A variable has an identifier (also known as a name, or a symbol) and a value.
- You can assign values to variables as the information you need to store becomes available.
- In certain cases you can initialize a variable without giving it a value.
- You can retrieve the information later on by referring to the name of the variable.
Pretty straightforward, and you can get really far with just what we've covered so far. Assign variable by giving it a name and some information, use it later. Got it.
But what happens when your program starts doing things you don't understand? What if your variable changed and you can't remember where it changed? What if you are sure you assigned the right value, but something still isn't working right and you want to hit your keyboard with a hammer? How do you go about protecting yourself from bugs that might be hard to track down?
Let's dig deeper and see what we find.
To mutate or not to mutate?
For this example I'll show you some code that is designed to do a series of steps: Assign a variable, then assign a second variable based off the first, then reassign the first variable, then print both variables. Then we'll start tweaking things. This is designed to showcase what you need to know about the variables you are assigning and what can happen if you try to change them.
Let's start with Javascript. Your first code example might look like this:
var a = "hello world"; // assign first variable
var b = a; // assign second variable
a = "hello again"; // reassign first variable
console.log(a); // print first variable
console.log(b); // print second variable
This code will run, and you will get the following output on the console:
hello again
hello world
Simple enough, right? You assigned the variables, reassigned one, then printed them both. Just like we planned.
Now let's update this to more modern syntax. Using var
in Javascript is now considered obsolete and is rare to see in new code. Instead of using the var
keyword, we can use the const
and let
keywords to declare the variables. We'll start with const
.
const a = "hello world";
const b = a;
a = "hello again"; // try to change a variable
console.log(a);
console.log(b);
This code will not run, and you will get a message Uncaught TypeError: invalid assignment to const 'a'
. So what happened?
By using const
, you told Javascript that you want to assign an immutable variable - a "constant". That means that once it is declared, it can't change. On the third line, you tried to reassign it, but since it can't change JS threw an error. By being explicit that the value should remain the same, you gave the Javascript runtime a hint to your intentions.
And this is the heart of mutability: Are you or are you not allowed to change something? And should you? We'll discuss this concept further in just a moment, but let's continue exploring first.
Getting the code to work again is pretty simple. You change the const
keyword to let
when you assign variable a
the first time. This tells JS to allow you to reassign the variable later in the code. By using let
, you are marking the variable as mutable, or able to change. So this code runs again:
let a = "hello world"; // Note the change here
const b = a;
a = "hello again";
console.log(a);
console.log(b);
At this point, you might ask yourself, "Why not just make every variable mutable? Then I can do what I want with them." The answer to this question is that creating immutable (unchangeable) variables is saving you from yourself. Being able to change every variable in your code at any time is like trying to fly an airplane by holding every knob, button, and control all at once. You forget what you are doing, you bump things by accident and pretty soon things get out of control and you crash. (In both cases!) By having information that can change, you then have burden of trying to find out where and how it changed when things don't go as expected. By creating immutable pieces of information, you eliminate this problem: If a variable is not what you expected, go to where it was assigned and see what's going on. In turn this means you can decrease the mental burden of thinking about your code, and code becomes easier to write.
.... sort of. I'll touch on why in a few minutes.
The next question that comes up is "Why not just make everything immutable? Then I don't have to think about it as much?" The answer to this question is that sometimes it does make sense to have information that you can change. For example, if you have a counter of some sort, it won't always have a value of 1. Otherwise it's not counting anything. So you often have to store information somewhere that is subject to change.
The happy path is to use immutable information (const
in JS for example) as the default. When you need a variable that needs to change, switch to let
. But think hard about why a variable should be mutable before you create a mutable variable! By understanding that it needs to change and where, you will be thinking harder about what your code is doing and you will be far less likely to make mistakes.
You should also note that in certain languages, All variables are immutable! This is the case in functional programming languages in particular, which tend to live by the idea that once data is created, it should not change. The paradigm for how these languages are used is also quite different from the most highly popular languages, but should never be ruled out: Functional programming is known for creating rock solid, reliable programs. The downside is that you will hear things like "A monad is a moniod in the class of endofunctors" which sounds like gibberish and is actually 100% accurate. I encourage you to explore functional programming at some point if you haven't already, but make sure you have a firm grasp on the basics first.
For now though, let's look at some more examples.
In JS, you can declare variables 3 ways:
var a = "hello"; // mutable - can change
let b = "world"; // mutable - can change
const c = "Hello again!"; // immutable - cannot change
There is technically a fourth way, by just assigning to the variable name such as a = "hello"
but a) this only works in non-strict mode (look up JS strict mode for further info and use it!) and b) - Just don't declare variables this way, it's bad form. Be clear with your intention and use the keywords.
You can also assign mutable variables without giving them a starting value, and you can assign multiple variables in the same line.
let a; // variable is initialized but doesn't have a value yet
let b, c; // 2 variables initialized
Note that you can't do this with the const
keyword, which makes sense because assigning a constant with no value is completely useless.
Let's take a quick look at some Rust code for our next example. In Rust, the most basic variables are are declared like this:
let a = "hello";
Notice that this is exactly the same code you would use in Javascript to declare a variable that you can change later. But if you tried to change this later in your code, Rust would throw an error. This variable in Rust is Immutable. That's because by default, all variables in Rust are immutable. You have to tell rust that you want it to be mutable, by adding the mut
modifier to the variable assignment:
let mut a = "hello";
Rust still allows constants, using the const
keyword (which cannot be modified with mut
) or by using the static
keyword. Depending on what you need to do, you can create mutable, static variables in Rust and the idea is that the variable lasts for as long as the program runs. But you'd better have a very compelling reason to do so, or you could be chasing some very difficult bugs later on.
These examples are to make a point: Things can look similar and might even behave the same at first glance, and you might think you know what they are doing, but your assumptions about their behavior could be wrong, and your code might fail as a result. So whatever language you are using, take the time to deeply understand how variables are assigned, when they are available in the code (lifetimes), when they are no longer available (also lifetimes), and how your code will behave as a result. Don't assume anything, or you could wind up chasing bugs that don't make sense, simply because what you think is happening isn't what is happening.
This is also nowhere near an exhaustive example of either language shown. Be sure to read the documentation for whatever language you are working with and to ask questions whenever you don't understand something. You will be assigning a lot of variables, so it's worthwhile to take the time to get it right.
Stacking things up to get things done
Before we can continue with variables, you should have a basic understanding of how code executes so the rest of the examples make sense. We will cover a few concepts briefly, but I encourage you to dig deeper on your own. I also simplified this a bit on purpose to make it more accessible.
When code is executing, you have the concept of a call stack, which is a data structure that the language controls, with a last in -> first out behavior. (Technically this is controlled by the runtime, but just say it's part of the language.) The language uses this call stack to execute your code. This is accomplished by adding stack frames, which are discreet units of code execution. Think of each stack frame like a box. You stack them on top of each other, and then begin unloading. You don't start on the second box until the uppermost box is empty.
Now, each stack frame can run code that can add more frames to the call stack (functions), but when this happens, the first frame that created the new frame must now wait for the stack frame above it to complete before it can continue (This is called a return btw). Again, back to our box analogy, imagine you are unloading a box and you get something out that says "Add another box to the top of the stack", so you do and now you must work through the uppermost box before you can continue on the one you were working on.
If you execute a function you are adding a stack frame. If you execute another function inside the first function, you are adding yet another stack frame. We can illustrate this with an example. We can create three functions and execute (call) each one inside the next, and the output will show us the stack frames listed in order.
function one() {
console.trace(); // I'll explain this shortly
}
function two() {
one(); // calling function 'one'
}
function three() {
two(); // calling function 'two'
}
three(); // calling function 'three'
When you run this code, it creates all three functions and then calls (executes) the function named three
. This creates a stack frame for the scope of three
, and inside that scope, two
is called. Inside two
, one
is called. Then inside one
, I called console.trace()
, which is a special method that prints out the stack frames from the uppermost "active" frame, all the way to the root frame which is where code execution starts. (Which JS calls ) This is called a stack trace.
What you get is this something similar this for the top 4 entries:
one - debugger eval code:1
two - debugger eval code:1
three - debugger eval code:1
<anonymous> - debugger eval code:1
Depending on where your code executes, this might look different, and might have more info underneath, but this is your call stack that you just created and it should have entries, for one
, two
, three
, and <anonymous>
in order. (If you try to do this in an online playground like Codepen it probably won't work do to technical reasons specific to those kind of platforms. Try to do this in a local development environment if you want to try it.) The uppermost entry is the one
function, just like we expected, and you can see two
, three
, and the root <anonymous>
frames as well, stacked on top of each other.
You may have also noticed through writing and running other code that if you write code that produces an error, you get a message that includes a similar stack trace to the one shown above. (names of the frames will be different of course) This is not an accident, this is the language attempting to give you information on how to fix the error. Starting at the location the error was thrown, the message will lead you all the way back to the root scope, so you have an execution path to aid you while you look for the problem.
What do stack frames have to do with variables then?
It turns out, stack frames and variables are deeply interconnected. I'm not going to go deep on scopes, which govern when you have access to which variables, but you should look this up as further reading and I will touch on them later in this series as well. (If you follow tutorials for your language of choice they will nearly always explain this also.)
What we will cover though is how variables are stored in memory. This discussion is widely known as Value vs. Reference types, and Stack vs Heap Memory, for reasons that should be clear shortly. Again, I have simplified this a bit to make the concept easier to understand. As always, dig deeper once you get the concept.
Also, all examples here are in Javascript, but be sure to read the documentation for any other language you are using because some things will be different.
First, you have primitive types, which are the very basic data types that a language supports. These are basic, they are a known size in memory, and they are immutable. (more on this shortly) In JS, these are string, number, bigint, boolean, undefined, symbol, and null. Primitives are also known as value types, because when you use them, you are directly using the value.
Then you have reference types, which are more complex. They might not have a known size, and may consist of multiple types of data brought together in a collection. In JS, these are called Objects and the data they contain are called Properties. You will also come across collections of data of the same type, called Arrays in JS, which are a specialized type of object. Regardless, these types behave differently to primitive types. They are created the same way though, like this:
// Note 'const' declaration below, just like we saw before.
// We'll talk about this trickster soon!
const a = {
hello: "world"
}
Whenever a variable is created, it is stored in association with the stack frame that created it. For primitives (value types), the actual data is stored in a data structure that is dedicated to that stack, which is easy for the program to manage and is a linear data structure. (It also happens to be a stack, just of memory). The underlying values are also immutable. So how does it work when you reassign them? A copy is made of the value and the new copy is stored with the new variable name, inside the memory for the stack frame where the new variable was created.
This means that when you do this:
const a = 5;
const b = a;
You are storing the number "5" in two places in memory. In this case, variables a
and b
are part of the same stack frame, but that frame has 2 distinct pieces of memory in use for the 2 variables.
But what about reference types? They can be very big and very complex. The stack frame might not have the space to handle them. So the stack frame reaches out to a larger memory store, called the heap, which is bigger in size, unstructured, and generally can grow or shrink in size as needed. The way this works, is the program asks a part of your language, called the Allocator, for a block of memory of a size large enough for your information. (In some languages, like C, you as the programmer ask the allocator for the memory in a manual step) The Allocator then goes to the heap memory and finds a block that is not in use and is large enough for your information, marks it as in use, and comes back to the program with a reference to that section of memory, which is a set of directions to where the information can be stored. That reference is what gets stored in the stack frame: It's a known size of a known type, and small enough to fit inside the stack frame. Then the program can follow the reference to the heap memory and store the information.
There are 2 big implications for this memory behavior. Well address the first one in this section, which is speed, and get to the other one in the next section.
When you store or retrieve the data in a value type variable inside the stack memory, the value is stored in a linear data structure with very fast access. It's super fast to get that information or store it in the first place. This means your code can execute super quick. But when you initialize the variable for a reference type or ask for that data back, your code has to pause execution and take the time to interact with the allocator and the heap memory. Then it has to bring that info back and work with it. This adds time to the whole operation, as the heap memory is much larger, is unstructured and non-linear, and is much slower to work with.
// faster
const a = "hello";
console.log(a);
// slower
// Note 'b' is an object, created with '{}'
const b = {
hello: "world"
}
console.log(b);
This can have significant performance implications for your code. In some languages it is much more pronounced than others also. If time is a factor, you can save some of it by striving to store information in the stack memory (as primitive types) as much as possible. But you must also keep in mind that stack memory is limited and is preset when the stack is created, and if you run out of it, the stack overflows and the whole program crashes. (and possibly the operating system it's running on) So only start optimizing this way when you have a defined need to do so. Otherwise, do what makes the code easier to understand and write, and just keep this in mind. And again, learn the specifics for whatever language you are using.
When a constant isn't a constant - and immutable things mutate!
Remember when we were talking about mutability, and I said that using immutable info made the code easier to write? There are still some pretty big gotchas in this, and you need to be aware of them to truly understand what your code is doing.
Remember when I said there was another big implication for the way memory behaves? This is also where that will come into play.
In fact, this is where the variables discussion all ties together to help you fully understand these sneaky little bits of code. And it all begins back on the stack frame.
Let's start with value types, and let's review what we looked at above.
What do you think happens when you assign a variable based of of another variable like this?
let a = "hello";
const b = a;
On the surface, you would say that a
stores "hello", and b
stores the value of a
. This is mostly correct, but if you recall what we discussed above, what is really happening is b
is storing a copy of the value of a
. This means that the values of both variables will resolve to "hello", but it's 2 different, distinct copies of "hello" in memory.
That means if you then mutate variable a
, and then log both values, you will see them as different. This code:
let a = "hello"; // Note this is mutable :)
let b = a;
a = "world";
console.log(a);
console.log(b);
Produces this result:
world
hello
So far, so good. But now let's look at a reference type.
Remember that reference types store instructions in the stack memory for where to find the information. So when you create a reference type, then make a copy, what do you think happens on the stack frame? Let's take a look.
If you run this code:
// Note we are creating objects here, using curly brackets!
let a = {
hello: "world"
}
const b = a;
a = {
hello: "old friend"
}
console.log(a);
console.log(b);
You see this:
{ hello: 'old friend' }
{ hello: 'world' }
It turns out, a
and b
are behaving exactly the same as we expect. So far so good, right?.....
So what if we wanted to keep a
as immutable, create a copy of it, and then change something in the copy. The code will look like this:
const a = {
hello: "world"
}
let b = a;
b.hello = 'old friend'; // this reassigns the property value
console.log(a);
console.log(b);
We execute the code and we get this:
{ hello: 'old friend' }
{ hello: 'old friend' }
Wait, what just happened? We assigned a
as a constant, which means it can't change! We assigned b
as a copy of a
, per the rules above, then we changed the hello property of b
, but a
also changed. Doesn't this break the rules?
No, It doesn't. In fact, it's doing exactly what it's supposed to. I'll explain.
When we worked with value types before, we were making copies of the value types as new variables. That is still what is happening in this example. The critical difference is that when we store a
, it is a reference type now, because it's an object instead of a primitive type. Remember, this means the actual information is stored on the heap, and just the reference is stored on the stack. So when we create variable b
, we are creating a copy of what the stack has stored for a
- which happens to be directions to the information on the heap. Different reference, but pointing to the same place!
So when the property in b
was updated, we didn't break any rules, because a
is just directions to the information, and so is b
. The underlying info can change all day long, and a
would still be correct. By changing the information in b
, you are asking the language to change the underlying information that both variables are pointing to! So now you have gone and mutated your "immutable" variable.
You might have noticed before that we successfully reassigned a reference type, with the code that looked like this:
let a = {
hello: "hello"
}
const b = a;
a = {
hello: "world"
}
So what is different, that made our code fail when we tried again? It's a subtlety of how the variables are being reassigned. And we can highlight this with another example.
This is VALID JS that will run as written:
// Note we are assigning this as constant
const a = {
hello: "world
}
a.hello = "again"; // Note we are reassigning a property of variable 'a'
Remember how I stated above that the underlying information for a reference type can change and the reference can still be valid? That is what we did here. a
is assigned to the same reference the whole time, and we are just changing the information inside that reference.
In contrast, this is NOT VALID:
const a = {
hello: "world"
}
a = {
hello: "again"
}
The key difference is that we are trying to assign an entirely new object to a
, which is asking the allocator to give us a new heap reference, and the const
keyword prevents that from happening.
Having fun yet?
It can get worse, depending on what language you are using. If you work in a language that has manual memory management, you can allocate heap memory in your code and assign the reference to a variable, use it, and then free the memory up again (called deallocating), and then forget that it was freed up and try to use the variable again. This means your code is trying to use information at an address that was shown as free to the rest of the system - and may be in use by something else! If you extract that information, it might be totally useless, but it might be sensitive information that your code has no business using, and now this flaw in your code could be used to extract information from the underlying system. This is a security issue now!
Or you could go the other way and assign a reference type, but never have a mechanism to release it. This is called a Memory Leak, because your program will keep allocating new memory without releasing the used memory, and if it goes on long enough your computer will run out of available memory altogether, and the program (and possibly computer) will crash.
Rust has a borrow checker to avoid exactly this issue. Other languages that make it possible have common patterns for programmers to help them avoid it. No matter what though, it pays to know how your variables will behave.
On the other hand, sometimes this behavior can be useful. By having multiple references to the same source of information, you can use that information as a single source of truth for the rest of the application. Care needs to be taken to do this correctly, but nonetheless a seasoned programmer can use this to their advantage in many interesting ways.
What do you do about this memory issue then? Quite simply, you learn about it in the language you are using, so when you write code, you understand what it's doing. Some languages will make you clean up your own heap memory. Some will use a "Garbage collector" - an algorithm embedded into the runtime that periodically frees heap memory you aren't using anymore. Some languages will let you share variables across threads, and the rules will differ based on the language. Take the time to get familiar with the way your language handles this issue, because if you write code this will eventually matter.
So what's the takeaway?
If you get nothing else from this article, I want to leave you with one thing: Understanding how variables work in the language you are using will make you better at writing in that language. You will have a deeper understanding of what the keywords you are using are asking the language to do, and you will have more trust in the values you get from your variables when it comes time to use them. Ultimately you will be better prepared to do what programmers are supposed to do - Solve problems.
Computer programs are fundamentally just information in -> information out. What happens to that information in between is in your hands, and your variables.
Assign wisely!
What's next?
Join me when I publish the next installment of this series, which is all about taking control of your code. See you then!