StringBuilder - High-performance, buffer-based string manipulation

A blazing-fast buffer based string manipulation module.

Inspired by depthso’s table-based StringBuilder module.

Features:

  • Nearly 1:1 feature parity with C#'s StringBuilder class.
  • Way faster than native string concatenation
  • Reduces GC pressure by using a single mutable buffer, bypassing Luau’s immutable string overhead.
  • Automatic buffer re-sizing
  • Supports being compressed into a single buffer,
    then later decompressed back into a StringBuilder object
    (perfect for datastores or for sending it over the network).
  • Strictly-typed and Robust™ (don’t quote me on that though, there may be some edge cases)
  • Easy to use API with straight-forward function names, descriptions and comments

This is slower than table.concat but is WAY more capable, so its worth that cost.
I think this might be more capable than Roblox’s native methods as well.

I made this module for fun/practice and decided that I spent too much time on it to have it collecting dust in my inventory (Honestly I was too lazy to make the documentation, github page, forum post, etc..)
I might make improvements if/whenever I feel like it and you guys are more than welcome to send pull requests/issues on github.

Benchmarks (20,000 iterations, averaged over 3 runs):

  23:22:39.158  Native concat: 8.877099566666098  -  Edit
  23:22:39.158  Table.concat: 0.023049566666789662  -  Edit
  23:22:39.158  StringBuilder: 0.04590246666581758  -  Edit
  23:22:39.159  Table.concat vs native: 385x  -  Edit
  23:22:39.159  StringBuilder vs native: 193x  -  Edit
  23:22:39.159  Table.concat vs StringBuilder: 2x  -  Edit
Benchmark Script
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StringBuilder = require(ReplicatedStorage:WaitForChild("StringBuilder"))

task.wait(3) -- wait for the game to load and stabilize if playtesting

local TEST_RUNS = 20000
local gc_wait = 3 -- make it more fair by allowing gc to finish before the next benchmark
local t = {}

-- native concatenation
local function test_native()
	local s = ""

	local start = os.clock()
	for i = 1, TEST_RUNS do
		s = s .. "var " .. i .. " = " .. i
	end
	local dt = os.clock() - start

	s = nil
	return dt
end

-- table.concat
local function test_tableconcat()
	table.clear(t)

	local start = os.clock()
	for i = 1, TEST_RUNS do
		t[i] = "var " .. i .. " = " .. i
	end
	local s = table.concat(t)
	local dt = os.clock() - start

	s = nil
	table.clear(t)

	return dt
end

-- StringBuilder
local function test_stringbuilder()
	local sb = StringBuilder.new() -- non pre-allocated stringbuilder (so including resize costs)

	local start = os.clock()
	for i = 1, TEST_RUNS do
		sb:AppendList("var ", i, " = ", i)
	end
	local s = sb:ToString()
	local dt = os.clock() - start

	return dt
end

-- multiple runs
local RUNS = 3

-- function for getting average results over x runs
local function avg(fn)
	local total = 0
	for _ = 1, RUNS do
		task.wait(gc_wait)
		total += fn()
	end
	return total / RUNS
end

local native = avg(test_native)
local concat = avg(test_tableconcat)
local sb = avg(test_stringbuilder)

-- Print results:
print("Native concat:", native)
print("Table.concat:", concat)
print("StringBuilder:", sb)

print("Table.concat vs native:", math.round(native / concat) .. "x")
print("StringBuilder vs native:", math.round(native / sb) .. "x")
print("Table.concat vs StringBuilder:", math.round(sb / concat) .. "x")

Example Code
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StringBuilder = require(ReplicatedStorage:WaitForChild("StringBuilder"))

local textBuilder = StringBuilder.new() -- You can specify the builder's starting size in bytes if needed

-- Right now the textBuilder is empty, but we can add some text to it:
textBuilder:Append("Hello, World!")

-- Now the textBuilder contains "Hello, World!", so if we do:
print(textBuilder) -- or print(textBuilder:ToString())
-- The output is going to be: "Hello, World!"

-- Lets replace every occurrence of "World" with my name
textBuilder:Replace("World", "Ghost")

-- Now, if we print again:
print(textBuilder)
-- The output this time will be: "Hello, Ghost!"

-- Also, instead of calling methods with ":" (like :Append()),
-- we can call them directly like this:
StringBuilder.Append(textBuilder, " Let's continue this string!")

-- This avoids method call overhead, but is a bit less intuitive/readable
-- So just pick your poison (very little overhead anyways)

-- By the way, Append adds exactly what you give it
-- It does NOT add spaces automatically
-- Use " " manually or textBuilder:Space() if needed:

textBuilder:Append(" <- Like this") -- space is included manually here
textBuilder:Space() -- appends a space character
textBuilder:Append("<- Or like this!") -- no need to add a space, Space() already handled it.

-- StringBuilder also has chaining support:
textBuilder:Append("Hello,"):Space():Append("Again!")
print(textBuilder) -- output: "Hello, Again!"

github-source  documentation

6 Likes

New Update:

  • Added EnsureCapacity() function that ensures that the StringBuilder’s capacity is at least the given capacity. (returns success & capacity)
  • Added SetCharacters(), GetCharacters() functions.
  • Added Compare() function that does a native buffer byte comparison on whether 2 StringBuilders contain the same data or not.
  • Made Space() and Newline() functions use writeu8 and raw keycodes instead of the Append function (should be way faster now).
  • General performance improvements, predictability improvements and bug fixes across all functions.

Documentation needs to be updated to match the new additions.
If someone could help me make the api references automatic, that’d be great (I tried to use moonwave but it kinda confused me)

(oh and i forgot to keep track of the changes i made so this may not even be all of it…)

2 Likes

I updated the documentation to reflect the new changes.

1 Like

i dont think anyone cares but i made some minor improvements and fixes (mostly just making the code more readable)

The performance gains on the AppendList/Append calls are probably negligible for most use cases, but the buffer reuse is the real win here. Most people just use table.concat and call it a day, so this is only useful if you’re actually hitting GC bottlenecks.

thats exactly why i made this buffer based.
(funny enough, AppendList uses table.concat then inline appending cuz its just the fastest concatenation method we have available)

typewriter scripts could be made more easily for example and they’d only incur a one time memory allocation fee.
or for a game where players write huge books, you can compress it and upload it to the datastore.
then later fetch it and open it back up into a StringBuilder class/object.

(maybe those examples are a bit niche but i cant think of anything else lol)

imho, its a pretty nice string library replacement (for most cases).
i could probably expand StringBuilder’s assortment in the future by adding more functions from the string library (preferrably with buffer only logic for no allocations)

The datastore compression idea is actually a solid use case. Most people wouldn’t think to use a buffer for that, but it definitely beats dealing with massive string allocations and re-parsing everything manually.

yeah, and all of that is built-in

The built-in compression is a nice touch. It makes the whole thing feel more like a proper utility rather than just a benchmark vanity project.