Fast replacement for assert(): affirm()

roblox-badge wally-badge github-badge


affirm()

I’m one of those fans of assert: it makes things shorter, simpler, cleaner. But its performance issues make custom made asserts be better than the built-in one. That’s affirm, the good assert.

If you haven’t yet, check out this post.


Why not if-blocks?

They’re a little bit longer than affirm statements and not that readable if you ask me. Done.

if not typeof(value) == "number" then error(string.format("expected number, got %s", typeof(value)), 2) end
vs
affirm(typeof(value) == "number", "expected number, got %s", typeof(value))

Usage

affirm is really similar to assert, only adding a third argument:

affirm(condition: any, errorMessage: any?, ...: string | number?): ()

Arguments after the error message will automatically format to the second argument, no need to call string.format. See the example:

local affirm = require(path.to.this.module)

local var1 = 687
local var2 = "foo"

affirm(var1 > 0, "number isn't positive [%+i]", var1) -- OK

affirm(typeof(var1) == "number", "expected number, got %s", typeof(var1)) -- OK

affirm(
    typeof(var2) == "number",
    "expected number, got %s",
    typeof(var2)
)
-- Error: expected number, got string

Features

  • The second argument, errorMessage, accepts any valid data type as value. This includes tables or instances to be thrown as errors.

    image

  • The error message points to the caller of the failed function rather than the function that calls affirm by using error(..., 3) internally. Useful for debugging.

  • Trying to format an error message with invalid arguments (anything other than strings or numbers) will throw a nice and detailed error pointing these bad arguments.

    affirm(false, "%s, %s", "Nice", Instance.new("Part")) -- "Nice" is #1, Part is #2
    

    will throw

    affirm() -> following arguments expected strings or numbers, got: #2 Instance
    
  • The function is completely typed and documented, open-source under the MIT License (do whatever you want with the code) and available as a wally package. Enjoy :kissing_closed_eyes:!

Also, if you ask me for the name: affirm has the same number of characters as assert and means the same.

8 Likes

I have read this post and does affirm still faster than assert?

Why make a resource for a single function? I don’t think this is substantial enough. I mean, really, this:?

function assert<a, b>(assertion: a, msg: b)
    if not (assertion) then
        error(msg)
        return nil
    end
    return assertion
end

You just made it longer by adding functionality exactly like string.format into the function for no reason, if anything, just using interpolation is way faster than this.

3 Likes

The problem with assert is evaluating the string.format even if the condition is not met. This automatically happens when you write string.format inside the function, even if the function doesn’t error. String formatting is pretty expensive and will add up to the call time.

affirm just takes the string and the arguments by separate. if the condition is not met, then string.format is called for the arguments. The problem is not using string.format but using it when the condition is already met.

affirm is equivalent in performance to an if-block if the condition is met. If it is not, that means you should worry about the error and not the performance

1 Like

string concatenation is still string formatting. It may be shorter but it’s still slow.

local t = os.clock()

for i = 1, 1000000 do
	assert(typeof(i) == "number", string.format("Bad argument %i", i))
end

print(os.clock() - t)
0.36739289993420243  -  Server - AffirmBenchmark:7
0.3325527999550104  -  Server - AffirmBenchmark:7
0.36400249996222556  -  Server - AffirmBenchmark:7
0.32683879998512566  -  Server - AffirmBenchmark:7

local t = os.clock()

for i = 1, 1000000 do
	assert(typeof(i) == "number", "Bad argument "..i)
end

print(os.clock() - t)
0.25457700015977025  -  Server - AffirmBenchmark:7
0.26714199990965426  -  Server - AffirmBenchmark:7
0.2632478999439627  -  Server - AffirmBenchmark:7
0.2641902999021113  -  Server - AffirmBenchmark:7

local affirm = require(game.ReplicatedStorage.Puzzle.affirm)

local t = os.clock()

for i = 1, 1000000 do
	affirm(typeof(i) == "number", "Bad argument %i", i)
end

print(os.clock() - t)
0.03468180005438626  -  Server - AffirmBenchmark:9
0.03711770009249449  -  Server - AffirmBenchmark:9
0.05186190013773739  -  Server - AffirmBenchmark:9
0.03708709985949099  -  Server - AffirmBenchmark:9

Additionally, affirm syntax it’s still a little bit shorter than if-blocks with concatenation and (this is up for your personal opinion), affirm looks cleaner and more understandable to me.

if not typeof(value) == "number" then error("expected number, got "..typeof(value)), 2) end
affirm(typeof(value) == "number", "expected number, got %s", typeof(value))

I understand this one. I’m breaking nº 1 rule for simple code: don’t overcomplicate it. Fortunately, affirm has very little code and too much documentation, which isn’t that bad.

It is already stated in the gist that part of the code, like the errorArguments function or the pcall is unnecessary but improves debuggability. Since it doesn’t really affect performance and it improves my QoL, it’s here to stay.

And… I use this not only in games but also libraries so having it as a package helps me. Also, the download badges were designed for this resource but also for future ones and they’re free to use for anybody; everything has its benefits.

Your example would be just as slow as the original assert to make it like affirm you would do

function Affirm(assertion, ...)
    if assertion == true then return end
    error(string.format(...))
end

Now the message is only formatted if assertion is not true

2 Likes

EDIT: My idea has already been thought up of here:


Not to take away from your library but I just had another idea:

function declare(conexp: true | string)
	if conexp ~= true then error(conexp) end
end

----

declare((1 ~= 2) or "1 equals two????")
declare((player == Players.LocalPlayer) or "Wrong player!")

Benchmarking:

local t = os.clock()

for i = 1, 1000000 do
	assert(typeof(i) == "number", string.format("Bad argument %i", i))
end

print("TEST 1 - Assert", os.clock() - t)


for i = 1, 1000000 do
	affirm(typeof(i) == "number", "Bad argument %i", i)
end

print("TEST 2 - Affirm", os.clock() - t)

t = os.clock()

for i = 1, 1000000 do
	declare(typeof(i) == "number" or string.format("Bad argument %i", i))
end

print("TEST 3 - Declare", os.clock() - t)

My results:

TEST 1 - Assert 0.21682430000510067
TEST 2 - Affirm 0.24020060000475496
TEST 3 - Declare 0.018935300002340227

Also a bit more unreadable, but you could use string formatting with this and not lose any performance.

Advanced test (more fancy formatting)

Adding this to the benchmarking:

t = os.clock()

for i = 1, 1000000 do
	declare(typeof(i) == "number" or string.format("I wanted the number %i but instead I got the number %i. Why is this? %s %d %f %s", i, i, "TEST", 123123, 324.2342, "sdfsdf"))
end

print("TEST 4 - Declare+", os.clock() - t)

New results:

  13:24:07.097  TEST 1 - Assert 0.21436379999795463
  13:24:07.121  TEST 2 - Affirm 0.237631700001657
  13:24:07.139  TEST 3 - Declare 0.018795800002408214
  13:24:07.158  TEST 4 - Declare+ 0.01880679999885615
4 Likes

Thanks for sharing the idea! I haven’t considered ternary operators tbh but looking at the example it seems much cooler hehe.

Your affirm tests must have run in strange conditions because they are giving much slower results than they are expected to give. However, I’ve tested declare and it’s much faster, even more than Luau’s ternary operator:

declare(if typeof(i) == "number" then true else string.format("Bad argument %i", i))

Now I have to consider if I prefer the fast function or the pretty one…

1 Like

To be honest, the only place I see assert used (in my own code) is really libraries or plugins where user input can be wrong. In this case, such miniscule differences in speed really make no difference at all.

If you are performing assertions many times per second, then asserting is not suitable for this scenario anyway and there is no alternative but to rework your code.

In essence, I don’t think it’s sensible to worry about the efficiency of assert anyway. There are so many more inefficiencies that take longer yet we do nothing about them. Luau in itself runs under a VM which makes it much slower than if it was compiled into machine code (no idea if something like this exists already) and ran off of it. Assert is one of the smallest offenders.

If you have been experiencing performance issues due to assert, then that is a different story and you may benefit from using alternatives but I personally never had any issues…

2 Likes

In general I try to not care about these things but I must admit that I take an extra responsability when developing public libraries since who knows who may be using them one day. I’ve heard the case of BitBuffer being much slower than it should just for the mere use of assert.

There are obviously many factors in play, but my point is that the developer doesn’t know how may the people use its library. Thankfully differences between affirm and declare speeds aren’t that big, but I love the fact of declare being that simple and still more debuggable than affirm.

2 Likes

Bumping the post to note a little difference that’s nice to have into account while comparing affirm to declare:

local Part = Instance.new("Part")
affirm(Part, "Failed")
-- No error
local Part = Instance.new("Part")
declare(Part or "Failed")
-- Error: Failed 

This happens because declare checks if the first expression is equal to true, not if it is a true statement. Part is not equal to true but to Part, although Part is a true statement in the sense that it exists. affirm uses not to check if the condition is met or not, which makes it possible to describe things like Part ~= nil just as Part.

For anyone willing to use affirm or declare, a part from all the little neat differences between assert, note that sadly nor of these functions allows for type assertion as it happens with the built in assert, which can be frustrating for writing typed code in Luau.

2 Likes

Hello, sorry for bumping into an old topic.
Technically, this module is still better than having to do this for every module right?

if typeof(enemy) ~= "string" then
	error(`[EnemyService]: To spawn an enemy, the enemy parameter must be a 'string' of the enemy's name`)
	return
end

Which essentially have you sacrificing unnecessary lines + having to call returns whereas you could just do this:

-- Ensure that the enemy parameter is a 'string' type
Affirm(typeof(enemy) == "string", `[EnemyService]: To spawn an enemy, the enemy parameter must be a 'string' of the enemy's name`)

I just wanted insight on this, thank you!

The inline if-statement will always be faster because you don’t need to do a function call.

All this resource does is add a variadic for string formatting, so the evaluation doesn’t need to be done unless required. It is the exact same as:

local function Affirm(Condition: boolean, Message: string, ...)
	if not Condition then
		error(string.format(Message, ...))
	end
end