MAJOR UPDATE (2021-03-14)
Introduction
In this updated post, I complete the previous version of my answer with a collection of all the information we’ve gathered throughout this topic, and briefly mention all the improvement aspects we’ve discussed.
The timer function introduced by original poster doesn’t need any serious improvements. It is efficient, and almost any further changes can be interpreted as micro-optimization.
Minor improvements
This sub-section includes only the easy, necessary and/or recommended changes, targeting process time, readability, touches accuracy, and code consistency.
1. time and string naming
Variables shouldn’t be named “string” or “time”. Even though such variables may be used to store data, they are generally reserved for built in functions and lua globals. time is abbrevation for os.time(), and string stores string library methods (similar to table library).
Example:
string.find() ; table.insert()
2. Defining variables
It’s a good practice to define variables outside rapidly running functions. Performance difference is likely negligible, but it’s a plus from readability aspect. Only apply changes to define variables through loops.
3. Functions and loops
@DarthFS mentioned making the code more compact and removing abstraction by not calling functions rapidly. That is a good idea, especially in this case, but the final choice depends on complexity of the process. Often enough carrying out the whole process in one function reduces readability in exchange for minor improvement. Code may appear much longer. It’s best to avoid calling function rapidly, but it’s not at all critical if we don’t pass large parameters along.
Read more about call stack here: https://www.learncpp.com/cpp-tutorial/the-stack-and-the-heap/ (link by DarthFS).
Filling call stack with unnecessary large data increases the chances of so called stack overflow happening.
4. os.time() vs. os.clock()
@PysephDEV yes, os.time() is generally a good choice for timers. However, os.clock() is very useful not only for benchmarking, but for general precise use as well. For instance when we are creating a countdown timer displaying minutes till an event (e.g. countdown starting with 5 minutes).
The Roblox Dev Hub currently describes os.clock() as useful mainly when benchmarking, but its role has become broader.
Since tick() has been recently deprecated, we don’t have any other methods that could return us precise delta time. os.time() displays seconds only, so os.clock() is now used for any operation requiring relatively rigorous precision.
I don’t see any serious reasons why not to use os.clock() in processes. Perhaps it’s not necessary in this case, but the important evaluation to do is determine what we are really trying to achieve.
We aren’t calling os.time() to format times, but instead calculating difference (time delta) between the starting point and current time. Using os.clock() and os.time() is done in the same manner, except one result is more precise than the other.
Some quick benchmarking:
os.clock() --> around 1.5 * 10^7s
os.time() --> around 4 * 10^7s
--> No visible performance loss; difference is completely negligible.
5. Waiting time
wait() function with time argument in brackets is very useful, but is not completely accurate. It’s part of 30Hz pipeline, yields the code, and runs every second rendered frame at best. It isn’t always reliable or consistent, hence occasional delays, such as:
wait(0.5) --> 0.501, 0.507, 0.517, 0.56 ; up to seconds in some cases
Wait time really depends on other scripts as well. These delays don’t affect your timer much, because you are not displaying miliseconds. With waiting time of 0.5s, you may experiency delays in display (e.g. time = 5s, printed at 4.9s or 4.8s), which is not to be confused with displayed time accuracy. Why?
Because you are calculating the difference between starting time and current time. The time we are calculating the difference at has no impact on accuracy.
Conclusion of this subsection: rather reduce waiting time to around 0.3s, meaning your function will run around 3 times. Display time in seconds will look smooth in most cases.
Recommended script
local START_TIME = os.time() + 600
local label = script.Parent.Parent.Closing -- shorten paths
local hours, minutes, seconds, total, text
local function countDown()
repeat
total = START_TIME - os.time()
text = ""
hours = math.floor(math.fmod(total, 86400) / 3600)
minutes = math.floor(math.fmod(total, 3600) / 60)
seconds = math.floor(math.fmod(total, 60))
-- Note: string ..= "happy" is the same as string = string .."happy"
if (hours > 0 and hours < 60) then text ..= hours ..'h ' end
if (minutes > 0 and minutes < 60) then text ..= minutes ..'m ' end
if (seconds > 0 and seconds < 60) then text ..= seconds ..'s' end
-- Task
label.Text = text
wait(.3)
until total <= 0
end
-- Usage
countDown()
Precision - additional information
Throughout the topic, a discussion about my mentioning of RunService.Heartbeat has been discussed. I mentioned it purely as an extra tip if you ever decided to display miliseconds. There have been some misunderstandings regarding the idea.
If precision matters, RunService.Heartbeat is the right choice. This applies for displaying in miliseconds.
Expand this fold to read more!
Now, I accidentally told some wrong information here: Heartbeat doesn’t run at the rate of 30Hz only. That used to be true in the past, but doesn’t apply today. It is, however, much more consistent than wait(). Heartbeat, as @DarthFS said, depends on game frame rate (FPS), although if no serious stuttering is present and game performs well, Heartbeat:Wait() is pretty consistent. There is a big framerate drop when client joins or server starts, but consistency is established quickly.
Note: Heartbeat will be replaced by its successor PostSimulation in the near future.
What is Heartbeat?
Heartbeat is an item, an event that fires every frame after game physics simulation. Delta time (step) is equal to the time elapsed since the previous frame.
Using Heartbeat:Wait() in your current situation would be a big waste of resources. I never said you should use Heartbeat in your function, only if you added miliseconds. Why Heartbeat for miliseconds?
--[[
Heartbeat:Wait() time according to framerate:
50 fps --> =~ 1/50s
Display:
]]
00 : 00: 00: 00 --> hours, minutes, seconds, miliseconds
Heartbeat is necessary in this case. Just extending the example here.
Micro optimization
We’ve now covered all the most important “angles”. There isn’t much to improve now. Optimization on micro-level is not always a good idea, for the reasons explained by both PysephDEV and DarthFS.
Premature optimization in a nutshell is a technique often slightly improving the code, but sacrifices readability. Sometimes called literate programming is a principle of writing neat code whoose primary goal is to be easily understandable by at least an intermediate human reader.
Is premature optimization welcome? Usually not, at least not in Roblox environment. Difficult code is often difficult to manage. Rarely is optimal performance so crucial that we decide to sacrifice code readability.
However, I can’t completely agree with the statement “premature optimization is the root of evil” in this forum section. The purpose of code review is to review the code and discuss different approaches. Almost anything is welcome. String concatenation is not critical here at all, but is a good discussion opener. So no, I don’t see any problem in mentioning it. In fact, it’s an advantage, as @blue1010123 and anyone else reading might learn something new.
String concatenation
In lua, we can concatenate strings using the dot (…) operator. Everything is perfectly fine, as long as we don’t overdo it, as string concatenation to some extent negatively affects performance. Even though I already used this quote below, it seems reasonable to repeat it here.
… there is a caveat to be aware of. Since strings in Lua are immutable, each concatenation creates a new string object and copies the data from the source strings to it. That makes successive concatenations to a single string have very poor performance.
( - RBerteig, Stack Overflow)
The rest of this post is intended for comparision of various ways to concatenate strings.
I won’t be performing any tests, because they are already described in other posts, but briefly explain each method.
How does string concatenation work in lua?
local ourString = "" --> string created
ourString = ourString .. "What " --> new string created
ourString = ourString .. "a " --> new string created
... -- same process until
ourString = ourString .. "world!" --> new string defined again
-- Altogether: 5 individual variations in memory for one sentence.
Again, not at all bad when we are gradually combining a couple of strings, but relatively bad when we have a lot of them.
Each time a string is created, it occupies a certain place in memory. It’s a very small place, so not much compared to the overall memory size, albeit efficient code is very important, because memory is still limited.
- One-line concatenation
local ourString = "What ".."a ".."wonderful ".."world!"
Such concatenation was the most performant in tests. Only one string is created very quickly. The only disadvantage is manual concatenation. There may be too many strings to combine them in one line. Spliting it into multipe smaller strings is also alright. But what if we have 20 or even hundreeds of strings?
- Concatenation using a loop and dot operator
To test how performant gradual concatenation is, I concatenated 10, 20, 1000, and 2.5 * 10^5 randomly generated letters (shortest possible strings).
As expected, string concatenation performed badly, because we created 250 000 strings to format the final one. Memory usage increased by more than 130 mb.
- Concatenation using a loop and table.concat
As an alternative, the idea was to concatenate strings using tables. Previously, the process involved relatively unnecessary step of creating a new table, inserting characters into it and concatenating elements of the newly created table. Luckily, we can skip that step, and simply store concatenated result of orignal array. The precondition is to have strings stored in form of a table.
Results and conclusions
One-line concatenation seems ideal for combining lower number of strings.
Concat. in multiple lines is acceptable as well, but creates a couple of more strings along the way.
Both methods are straightforward and simple yet efficient. It is about 2 times faster than table method.
Dot operator performs poorly when a lot of strings need to be processed. One-line concatenation is just as efficent, despite being impractical in such cases.
Table method performs best with higher numbers of strings, is significantly faster and performance friendlier. When used for processing of lower number of strings, for example 5, it is a better option compared to gradual string concat., but slower than one-line concatenation.
The table method is far from “hideous”, even though it seems so at first sight. Copying to another table was unnecessary and worse option than plain table.concat, but still quite performant.
Code to compare table.concat and gradual concat methods
local NUMBER_OF_STRINGS = 5
local array = {}
local char, t, s
for i = 1, NUMBER_OF_STRINGS do
char = math.random(97, 122)
table.insert(array, string.char(char))
end
local tableTime, dotTime
-- STRING CONCATENATION --------------
dotTime = os.clock()
s = ""
for _, v in ipairs(array) do
s = s .. v
end
dotTime = os.clock() - dotTime
--------------------------------------
-- TABLE CONCATENATION ---------------
tableTime = os.clock()
t = table.concat(array, "")
tableTime = os.clock() - tableTime
--------------------------------------
--------------------------------------
print(tableTime, dotTime)
print(( tableTime < dotTime and 'Table concat. method was faster.' ) or 'String concat. method was faster.')
Code to compare all three methods on a short string
local strTime_sing, strTime_mult, tableTime, t, s, txt
local array = {"This ", "is ", "a ", "long ", "sample ", "string", "!", " =)"}
for i = 1, 500 do
-- TABLE CONCATENATION -----------------------------------------------------
tableTime = os.clock()
t = table.concat(array, "")
tableTime = os.clock() - tableTime
----------------------------------------------------------------------------
-- SINGLE LINE CONCATENATION -----------------------------------------------
strTime_sing = os.clock()
txt = "This ".."is ".."a ".."long ".."sample ".."string".."!".." =)"
strTime_sing = os.clock() - strTime_sing
----------------------------------------------------------------------------
-- GRADUAL CONCATENATION ---------------------------------------------------
strTime_mult = os.clock()
s = ""
for _, v in ipairs({"This ", "is ", "a ", "long ", "sample ", "string", "!", " =)"}) do
s = s .. v
end
strTime_mult = os.clock() - strTime_mult
----------------------------------------------------------------------------
end
print("Single line:".. strTime_sing ..", multiple line: ".. strTime_mult ..", table: ".. tableTime)
local min = math.min(strTime_sing, strTime_mult, tableTime)
print(
min == strTime_sing and "single string concat" or
min == strTime_mult and "multiple string concat" or
"table concat"
)
Alternative script with included micro optimization
local START_TIME = os.time() + 600
local label = script.Parent.Parent.Closing -- shorten paths
local hours, minutes, seconds, total, x
local function countDown()
repeat
total = START_TIME - os.time()
hours = math.floor(math.fmod(total, 86400) / 3600)
minutes = math.floor(math.fmod(total, 3600) / 60)
seconds = math.floor(math.fmod(total, 60))
if (hours > 0 and hours < 60) then x = 3
elseif (minutes > 0 and minutes < 60) then x = 2
elseif (seconds > 0 and seconds < 60) then x = 1
end
-- Read further to see what's happening in the following statement.
label.Text = ((x > 2) and hours .. 'h ' or '') .. ((x > 1) and minutes .. 'm ' or '') .. seconds .. 's'
wait(.3)
until total <= 0
end
-- Usage
countDown()
This post got so long that I’ll have to update it later.
In the final part, let’s explain what what short-circuit evaluation is (used above).
Short-circuit evaluation (also known as minimal evaluation and McCarthy evaluation) is a special semantics typical for some programmig languages. Structure in lua:
local a = true
local variable = a and "a is true" or "a isn't true"
print(variable)
Only if the first condition is met, the second argument is executed. Otherwise, script continues with alternative argument (after or).
The above code is equivalent to
local a = true
if (a == true) then
variable = "a is true"
else
variable = "a isn't true"
end
print(variable)
Short-circuit evaluation is very useful, because it’s short and simple. Above case shows a little more complicated use formed this way:
local x = condition1 and value1 or condition2 and value2 or value3
We can do multiple evaluations. How else can we use it?
local object = workspace:FindFirstChild("Part")
local name = object and object.Name or "default"
-- respectively
local name = (object == true) and object.Name or "default"
Hopefully you find this post useful @blue1010123, it took quite some time to write. I bet learning something new is better than getting some small corrections on your code back accompanied with a statement saying that there is nothing much to improve, and/or the function is written optimally. If any of the information seems confusing or you have any questions, feel free to contact me via private message. You can of course ignore some corrections. This post is meant to be informative after all.