Let’s say we need a cup of coffee. What we have at our disposal are one water kettle, one mini mixer, an empty cup and a teaspoon. We also have some coffee granules, sugar, water and milk. What we want as the outcome of the exercise is a cup of coffee and this is one way we can get it:
Make coffee
1. Put a teaspoon of sugar into the cup
2. Put a teaspoon of granules into the cup
3. Boil water in the kettle
5. Pour water to fill half the cup
6. Mix the contents of the cup
7. Add milk to fill the rest of the cup
8. Mix again
9. Serve
And there it is - a piece of pseudocode with an imperative program that explains how to make a cup of coffee. Yes, recipes are programs, all right!
What are some initial observations we can make:
- Order of the steps matters a lot - if you execute steps 5 and 7 before any other steps, you’ll mix an empty cup twice and end up with unmixed sludge.
- The reason why order matters is because every step modifies the state of our cup and the next steps depend on that modification in order to work properly. Step one changes the cups state from
empty
tocontains sugar
. Step two changes it fromcontains sugar
tocontains sugar and granules
and so on. - Therefore, mutable state makes order of execution important.
What else can we see:
- Not only does our program modify the state of our cup, it also modifies the state of the outside world - certain quantities of all the ingredients are consumed, our electricity bill increased just a little bit and finally, in step 9, we got our cup of coffee and our energy level just jumped. We’ll refer to all of these as side-effects.
- Since we do consume those resources, the next time we repeat the same program we may find that we ran out of milk. Or that it smells funny! Or the kettle just died, only a few days after warranty expiration to make things worse. So, not only does our program change the state of the outside world, it also depends on global state as well.
We can expand our definition and say that imperative programs consist of an ordered sequence of statements or commands that depend on current state of the world and cause changes to it in the process.
Structured programming
Let’s expand our example a little bit. Not everyone likes their coffee the way I do, so let’s take that into account and make the number of teaspoons of sugar variable. While we’re at it, let’s also make milk optional - if it is not wanted, we’ll just put water instead.
Make coffee
milk : yes | no
sugar : number of teaspoons
1. Count the number of teaspoons of sugar added - starting at 0
2. Put a teaspoon of sugar into the cup
3. Increase the count by 1
4. If we counted less than the desired amount jump back to 2
5. Put a teaspoon of granules into the cup
6. Boil water in the kettle
7. Pour water to fill half of the cup
8. Mix the contents of the cup
9. If milk is wanted jump to 10, otherwise jump to 11
10. Add milk to fill the rest of the cup
11. Add water to fill the rest of the cup
12. Mix again
13. Serve
This program is technically correct - go on, step through it a few times and check it yourself. However, it is
well… really ugly and not the easiest thing to read and let alone maintain. The main culprits are the parts
where we jump to arbitrary steps as we go along - the infamous goto
statement.
This issue was the reason for the first real upgrade imperative programming got - a variation of the paradigm called structured programming.
At this point, we may as well ditch the pseudocode and continue with JavaScript:
let cup = {sugar : 0, coffee: 0, milk : 0, water : 0, done : false};
let sugar = 2;
let milk = true;
for(let counter = 0; counter < sugar; counter++){
cup.sugar += 1;
}
cup.coffee += 1;
console.log('boiling water...');
cup.water += 0.5;
console.log('mixing...');
if(milk){
cup.milk += 0.5;
}else{
cup.water += 0.5;
}
console.log('mixing...');
cup.done = true;
console.log(cup);
Now this is an example you can actually run! Just copy/paste it into your favorite JavaScript console.
Just like in our pseudocode example, we start with an empty cup. Here it is represented with a JavaScript object with a field for each ingredient and one additional flag field that marks a “done” cup of coffee.
Then we have two variables that hold our coffee preferences. In this case it is a cup of coffee with milk and two teaspoons of sugar.
What follows is the main improvement that structural programming brought us - for
loop and if
conditional
statement. These operations are called control flow statements since they do exactly that - control the flow of the program. There are two kinds of control flow statements: conditional statements and loops.
We only need to take a look back at our previous example to see that conditionals correspond to goto jumps forward, skipping some statements that do not meet the criteria. Likewise, loops correspond to jumping backwards, so that the same statements are executed more than once.
Procedural programming
Let’s expand our example even further. So far we assumed that the cup is initially empty and clean. Of course, that’s not how the world works and a lot of the time our coffee preparation starts with dish washing. So, let’s add some code that simulates this:
cup.water = 0;
cup.milk = 0;
cup.done = false;
We will need to run these few statements every time we want to make a new cup of coffee, right before all the other statements. But we also may want to clean the cup as a separate task, even if we don’t want need the coffee immediately. So, these statements need to be used in two different situations.
What do we do? Do we copy-paste them and end up with two identical chunks of code in our program? There is a much better way to reuse this code - procedural programming.
The main innovation in procedural programming was introduction of named reusable pieces of code that capture some common functionality that needs to be invoked from different parts of the program. These modules were usually referred to as subroutines and there are two types of them:
- functions - accept zero or more parameters and return a result value. Note: this is not necessarily the same as mathematical functions or the ones from functional programming.
- procedures - accept zero or more parameters, change the global state and do not return a result value.
Since we need to change the state of a cup, we will need to write procedures. JavaScript only allows us to define
functions, but that is not a big deal, we can just ignore the return value - if you omit a return
statement, the
result will just be a special undefined
value. So, here we go:
let cup = {sugar : 0, coffee: 0, milk : 0, water : 0, done : false};
function cleanCup(){
cup.water = 0;
cup.milk = 0;
cup.done = false;
}
function prepareCoffee(sugar, milk){
cleanCup();
for(let counter = 0; counter < sugar; counter++){
cup.sugar += 1;
}
cup.coffee += 1;
console.log('boiling water...');
cup.water += 0.5;
console.log('mixing...');
if(milk){
cup.milk += 0.5;
}else{
cup.water += 0.5;
}
console.log('mixing...');
cup.done = true;
console.log(cup);
}
prepareCoffee(2, true);
We start with the empty cup initialized. Then we have two function definitions. The second one has two
parameters - one for each coffee making option. Finally, we have a function call to prepareCoffee
with
two teaspoons of sugar and milk.
What happens next is that the very first line in our coffee making function is a call to another function
cleanCup
. That’s how we ensure that the following code will always start with a clean cup.
And that’s how you re-use a cup. Also code!
Feel free to play with this example in the console, e.g. try preparing coffee a few times, then try cleaning
the cup and check the value of the cup
variable and so on.
Object-oriented programming
We started with a simple example, then added some more complexity to it. We found ways to keep the structure of the program well organized and even had some level of code re-use.
Obviously, there is still something wrong with the current version, otherwise we wouldn’t be introducing a new paradigm. Well, for starters, there is nothing to prevent anyone from running a statement like this:
cup.spit += 0.1;
The cup
variable is completely exposed to whoever wishes to tamper with it. Ideally, we only want the cup to
be changed using our procedures and not in any other way. We want to prevent anyone from spitting in our coffee.
We will do that using the hallmark principle of object-oriented design - encapsulation.
What we need to do is bundle the data structure that holds the current state of the cup with the operations that change it. Then we need to make sure that this state is only accessed through these operations and not directly.
let Cup = function(){
let sugar = 0;
let coffee = 0;
let milk = 0;
let water = 0;
let done = false;
this.cleanCup = function(){
sugar = 0;
coffee = 0;
milk = 0;
water = 0;
done = false;
};
this.serve = function(){
console.log({sugar : sugar,
coffee : coffee,
milk : milk,
water : water,
done : done});
};
this.prepareCoffee = function(neededSugar,
neededMilk){
this.cleanCup();
for(let i = 0; i < neededSugar; i++){
sugar += 1;
}
coffee += 1;
console.log('boiling water...');
water += 0.5;
console.log('mixing...');
if(neededMilk){
milk += 0.5;
}else{
water += 0.5;
}
console.log('mixing...');
done = true;
};
}
We created another function Cup
whose purpose is to create and initialize an empty cup and we bundled our
previous functions within it. We had to rename a few things here and there, e.g. prepareCoffee
parameters
are now called neededSugar
and neededMilk
to avoid the naming conflict and also to emphasize that they
refer to the expressed coffee preferences not the current amount of sugar or milk in the cup. Just to make
things clearer we also created a new function serve
to well… serve the cup as it is at the moment.
The main units of object-oriented programming are objects - data structures that consist of:
- fields - variables that contain the current state of the object - in our example variables
sugar
,coffee
,milk
,water
, anddone
. - methods - functions that access or manipulate fields of its own object -
cleanCup
,serve
andprepareCoffee
. - constructors - special functions that create and initialize the object -
Cup
function. - destructors - special functions that destroy the object and free up any taken resources. Since JavaScript is a garbage collected language, we don’t have this in our example.
Both fields and methods alike are referred to as members.
To achieve the encapsulation we need to make sure that some members are accessible from anywhere in the program,
while others are only accessible from the object itself, specifically from its methods. Some other languages like
Java or C# have explicit member access modifiers like public
and private
. In JavaScript we get the
functionality of private members by defining the local variables with var
within the constructor and the ones
we assign with this
are treated as public.
Please note that both fields and methods may be either public or private. It usually makes more sense to keep the fields private and methods public, but it is perfectly possible to have a private method or a public field.
We have a definition of an object, now let’s use it:
let cup = new Cup(); //create a new cup
cup.prepareCoffee(2, true); //prepare the coffee
cup.serve(); //serving the cup
//=> {sugar: 2, coffee: 1, milk: 0.5,
//=> water: 0.5, done: true}
cup.cleanCup(); //cleaning
cup.serve(); //now it's empty
//=> {sugar: 0, coffee: 0, milk: 0,
//=> water: 0, done: false}
What if we wanted a few more cups?
let cup = new Cup(); //create a new cup
cup.prepareCoffee(2, true); //sweet and creamy
cup.serve();
//=> {sugar: 2, coffee: 1, milk: 0.5,
//=> water: 0.5, done: true}
let another = new Cup(); //create another cup
another.prepareCoffee(0, false);
another.serve();
//=> {sugar: 0, coffee: 1, milk: 0,
//=> water: 1, done: true}
We just need to create another cup, prepare the coffee in it and serve. Note that if we were to just call
prepareCoffee
on the same object twice we’d get one coffee, then spill it, clean the cup and prepare the
other one.
Let’s test the encapsulation too. We shouldn’t be able to see or modify the private variables directly:
cup.milk;
//=> undefined
cup.coffee;
//=> undefined
//the only way we can get this info
//is by calling serve
cup.serve();
//=> {sugar: 2, coffee: 1, milk: 0.5,
//=> water: 0.5, done: true}
//let's try modifying some of the values
//e.g. we can try messing up the amount of sugar
cup.sugar = 7;
cup.serve();
//=> {sugar: 2, coffee: 1, milk: 0.5,
//=> water: 0.5, done: true}
//still the same - it works!
//but see what happens if you thy this now:
cup.sugar
//=> 7
//a new "public" field with the same name as
//the "private" field was created automatically,
//but the private one was not affected
//It's a JavaScript gotcha
//you can ignore it for the time being.
//Let's try spitting in coffee:
cup.spit = 0.1;
cup.serve();
//=> {sugar: 2, coffee: 1, milk: 0.5,
//=> water: 0.5, done: true}
//The coffee is untainted. Mission accomplished!
We’ve seen all three major evolution steps of imperative programming so far: structured, procedural and object-oriented. That and made a lot of coffee following step-by-step instructions. It is time to move on and try out some other ways we can write programs.