Writing Modular Code - A Noob’s Guide
Hello developers! It’s me, the noob, again!
Today, I will be talking more about modular coding, why it’s important, and explaining some scenarios.
I hope you will enjoy what I’ve been writing.
What is modular coding, and why is it important?
Modular coding is all about splitting your logic into several, independent and smaller functions, that are shared with all other scripts. More often than not, they are intended to be independent and are meant to do only one thing, and nothing else. While this might seem weird to you if you haven’t dived into modular coding before, this is a recommended practice that will significantly help you in the future. By writing modular code, you will accomplish / experience the following:
- It’s a lot easier to alter your code / refactor it
- When you make changes, you only have to change one script
- Your scripts are no longer a mess (with 1000+ lines)
- It’s easier to test your code (if you do that)
- It’s easier to understand the code
To understand how modular code is better, I will now be providing you with some scenarios.
Example 1 - Duplicating scripts/functions
If you are a coder, you’d be most likely lying if you said you have never done this before. At the time we’re doing it, we’re probably thinking “hey, as long as it works”, which is a horrible mindset. Let’s check out this simple code snippet below to calculate something, and then get the result of it. Note that this is only used for demonstration purposes.
function calculateAverage (...)
local sum = 0;
local amt = 0;
for i, val in pairs(arg) do
sum = sum + val
amt = amt + 1
end
return (sum / amt)
end
local avg = calculateAverage(1,2,56)
print("Average is:", avg) --> "Average is: 15.5"
Ah, perfect! A simple function that easily calculates the average number from the input given. Perfect! Now, you originally intended to use this in one script on the server, and then you realize later on that you need this function on the client too. What do you do? You copy the code above, and paste it into the client script. Then, even later on, you realize that this is needed in another script. What do you do? You paste it again, in the other script. And so on…
But why is this bad? It works, right?
-
Yes and no. It does work, but it’s now not easily maintained, and you have an “error” in your script. If you ever were to change this (as you should), you’re now stuck at updating the same function 3 times in 3 different scripts. And as you know, human errors can arise and one character wrong and your script don’t work. Now, by editing a function 3 times in different scripts, you have increased your chances of new bugs to arise by a lot.
-
You just added an unnecessary amount of lines to your scripts. Instead of having a module you can require, you have copy-pasted this function in 3 different scripts, adding even more lines. This will eventually make your code unreadable, and a pain to edit.
-
What if you also forget to update the function in one of the scripts on the client? Well, you think you just fixed this so you wouldn’t notice until there is an error because of it, which may not be obvious / shown at first.
can you spot the mistake in the script above?
Example 2 - Attempting to modularize your code, but not enough
You have also probably tried to modularize your code, but did you know that in most cases you’re doing it wrong? I will now be giving you some code below, that you can “analyze”, and you should figure out what’s wrong with it (when talking about modularizing code).
local DataStores = game:GetService("DataStoreService");
local PlayerStores = DataStored:GetDataStore("PlayerStores");
function getPlayerStats (player)
-- Remember, datastores can fail!
local data = PlayerStores:GetAsync(player.UserId);
return data;
end
It’s okay if you don’t see the issue immediately, but I’ll give you a hint: player
When we are writing functions first, we usually don’t think about making it modular, or we haven’t planned out enough of the technical details of what our script is doing, etc. The problem in the script above is that you’re providing player
as the parameter, instead of the player’s user id.
Why is that “bad”?
-
What if you don’t currently have the player instance, but their id. Then you have to get the player using the
Players:GetPlayerByUserId(id)
, and it’s just adding a lot of unnecessary code -
If you were to test your code, you’re going to have to start a game instance (or play in studio), instead of just calling the function from i.e. the command bar.
What you should do instead, is:
-- ...
function getPlayerStats (userId)
local data = PlayerStores:GetAsync(userId);
return data;
end
Because now, if you want to call your function in the future, but only have their id, you’re not out of luck!
Example 3 - Repeating logic
This is a common mistake, and it’s a horrible one. The point of creating modules is to make them (often) independent, and callable/obtainable by any script using it. If you start making your modules “prepared” for only one script / one type of script some of the points of making it modular are gone.
Let’s assume the code below:
function HTTP (url)
HttpService:RequestAsync({
Url = url;
Method = "GET";
});
end
The problem here is that it’s only working for one kind of an HTTP request (the GET request). So, what happens if you want to make a POST request? Maybe we can make a new function!
function HTTPPost (url, body)
HttpService:RequestAsync({
Url = url;
Method = "POST";
Body = body;
});
end
Problem solved! Right!?
-
No. Unless you have to, you don’t make new functions because of one parameter. It makes it less maintainable (which is a major convenience of writing code this way).
-
What happens when you want to add custom headers?
-
What if you have a function that processes the responses, and that you need to call each time you do an HTTP request? You’re going to have to update the code in all the HTTP functions you just made. And if you remember from example 1, this is not a good practice.
Example 4 - Depending on each other
This is also very common to do, but, before you read, know that this section doesn’t necessarily apply at all times. This is merely an example. I have several times before that most modules are intended to be independent.
Let’s assume we have two modules, Module1 and Module2. They are both modular to a certain extent, but not fully. Let’s see what their contents are!
Module1:
-- // Module 1 Contents
local afterCalculating = require(Module2);
-- Sum two numbers
return function (num1, num2)
local sum = num1 + num2;
return afterCalculating(sum, num1, num2);
end
Module2:
-- // Module 2 Contents
-- Do something after the calculation
return function (sum, num1, num2)
print("Sum is:", sum);
-- Tell some other scripts
tellScript();
updateGame()
return true;
end
Ah, amazing! Now I don’t need to have so much code in my “main scripts”!
I can now just do
local summer = require(Module1),
-- Less code is better!1!1!
summer(2,3);
Now, to make it clear. Less code does not necessarily mean better code. It just means less code. And, in this example, you can see how “well” it turned out. (Sarcasm intended)
But, what if I have another script that wants to call this particular sum function? What is the script going to do? Now I’m left with either making a new module with the same sum function, or I can just copy-paste the same code from Module1 into the other script. Problem not solved. From example 3 we know that this is also bad practice.
The applicable solution to this problem is to make Module1 return the sum, and then have the “main script” call the other functions, rather than making Modul2 do it.
BUT
There are also scenarios where modules should depend on each other. What if you have a module that for example wants to sum all the numbers and calculate the average?
In the module, we can do the following:
-----
-- // Module
local functions = {};
functions.sum = function (...)
local sum = 0;
for i, val in ipairs(arg) do
sum = sum + val;
end
return sum, #arg;
end
functions.average = function (numbers)
local sum, nums = functions.sum(unpack(numbers));
return (sum / nums);
end
-----
-- // Script
local functions = require(functions);
local sumNum = functions.sum(1,5);
local avgNum = functions.average({
1,2,3,4
});
print(avgNum) --> 2.5
print(sumNum); --> 6
For this example, having the functions.average
function to call the functions.sum
function is legitimate. This is because functions.sum
isn’t specifically designed only to be called by functions.average
, but any other script can also call functions.sum
and it will give them the correct result.
Conclusion
Well, that’s it for now. I hope you enjoyed another one of my posts, and I hope you learned something new, and/or enjoyed the content! Please let me know if you have any questions, suggestions or if there’s anything else you feel like I should add.