Effective Lua

NOTE: This is not a Lua tutorial or reference, just a place to record something interesting when I read Lua source code, which is version 5.4.3

lua command

-e flag

-- both of cases are correct
lua -e'print("hello world")

lua -e 'print("hello world")'

command prompt

  • command prompt default values
    • _PROMPT is “> “
    • _PROMPT2 is “>> “
  • we can set global values like below to change lua command prompts
export LUA_INIT='_G._PROMPT = "~ "; _G._PROMPT2 = "~~ "'

lua  -- then prompts will be changed to new values

Lua global stuff

  • print will first call __tostring metamethod in value’s metatable if it exists.
local t = {}
local mt = {
    __tostring = function()
        return "willmafh's table"
    end
}
setmetatable(t, mt)

print(t)  -- will print `willmafh's table`
  • number, string, boolean, and nil will print their values as normal.
  • for other type values, it will try to get __name first, if it’s not a string, then Lua default type name will be used.
local t = {}
local mt = {
    __name = "willmafh's table"
}
setmetatable(t, mt)

print(t)  -- `willmafh's table: 0x5f3167d3acf0`
local t = {}
local mt = {
    -- here __name should be a string, otherwise it's meaningless
    __name = function() return "willmafh's table" end
}
setmetatable(t, mt)

print(t)  -- `table: 0x5f3167d3acf0`

Lua global table

-- _G is Lua's global table
print(_G)

-- following can recursively get the global table
print(_G["_G"])
print(_G._G)

Lua version

print(_G._VERSION)

warn function

  • should use control message "@on" to turn on warning message emitting first
  • warn(msg1, msg2, ...) function can concat multiple string message
  • then should use control message "@off" to turn off warn function
  • there are only two control messages, "@on"/"@off", other messages start with ‘@’ are normal messages
warn("@on")
warn("hello world")  -- Lua warning: hello world
warn("hello", " from willmafh")  -- Lua warning: hello from willmafh
warn("@off")
warn("hello world")  -- won't log this warning message

setmetatable function

  • setmetatable will return its first argument, that is the table itself, it won’t create a new table
local t = {}
local mt = {}
local rt = setmetatable(t, mt)
print(t, rt)  -- here t and rt are the same value
  • if the object’s metatable has __metatable field, it means the metatable is protected, and we can’t use setmetatable again to change it
local mt = {
    __metatable = "the metatable is protected",
    __tostring = function()
        return "willmafh's table"
    end
}
local t = setmetatable({}, mt)
setmetatable(t, {})  -- error throw here
  • nil metatable argument will clear table’s original metatable if there is any
local mt = {
    __tostring = function()
        return "willmafh's table from __tostring"
    end
}
local t = setmetatable({}, mt)
print(getmetatable(t))  -- table: 0x603284328ef0

setmetatable(t, nil)
print(getmetatable(t))  -- nil

getmetatable function

  • if the object’s metatable has __metatable, then returns its value, it can be any type. otherwise returns the metatable itself
local mt = {
    __metatable = "the metatable is protected",  -- can be any type value
    __tostring = function()
        return "willmafh's table"
    end
}
local t = setmetatable({}, mt)
print(getmetatable(t))  -- __metatable value is printed

require function

  • require function will first search registry["_LOADED"] table, and if there is a required module, then it return directly
  • package table is the upvalue of require function
local registry = debug.getregistry()

--[[
string  table
table: 0x5efa18719710
table: 0x5efa18719710
--]]
local name, upvalue = debug.getupvalue(require, 1)
print(type(name), type(upvalue))
print(upvalue)
print(_G["package"])

error function

  • not only can log string messages, but can log error objects, which has __tostring metamethod in its metatable
local errobj = setmetatable({}, {
    __tostring = function()
        return "error from tostring meta method"
    end
})

error(errobj)
  • why does error never returns? Actually, all things the function under error does is to prepare an error message and put it on the top of the stack, then calling LUAI_THROW macro, which is implemented by longjmp in c, so we will back to where LUAI_TRY is called, which is obviously implemented by setjmp in c, finally will call report function to log the error message

arg table

  • lua will collect all arguments in arg global table. The script name will be at index 0, and the first argument will be at index 1 and so on. The interpreter name and its options before the script will go to negative indices. Indices <= 0 belong to the associative part of the table, and indices > 0 belong to the array part
lua -W arg_test.lua hello world
local arg = _G.arg

print(#arg)  -- 2, array part of the arg table
--[[
1       hello
2       world
--]]
for i, v in ipairs(arg) do
    print(i, v)
end

--[[
1       hello
2       world
0       arg_test.lua
-2      lua
-1      -W
--]]
for k, v in pairs(arg) do
    print(k, v)
end

registry table

  • the first element in registry table array is the main Lua thread, and the second element in registry table array is the global table _G
local registry = debug.getregistry()

-- thread: 0x5886909e72a8
print(registry[1])  -- main thread

--[[
table: 0x5886909e7c50
table: 0x5886909e7c50
--]]
print(registry[2]) -- global table _G
print(_G)
  • all require loaded modules, including lua file module and c libs module, are kept in registry["_LOADED"] table.
require "lfs"
require "cjson.safe"

local registry = debug.getregistry()

for k, v in pairs(registry["_LOADED"]) do
    print(k, v)
end

--[[
_G      table: 0x6461ef84bc50
os      table: 0x5786711edbb0
...
lfs     table: 0x6461ef8526f0
cjson.safe      table: 0x6461ef855a80
...
--]]
  • all loaded c libs will be kept in registry["_CLIBS"] table and each lib will be kept in two ways, one is key value way, the other is an array element
local lfs = require "lfs"
local cjson = require "cjson.safe"

local registry = debug.getregistry()

local clibs = registry["_CLIBS"]

--[[
1       userdata: 0x567153cf7fe0
2       userdata: 0x567153cfa700
/usr/local/lib/lua/5.4/lfs.so   userdata: 0x567153cf7fe0
/usr/local/lib/lua/5.4/cjson.so userdata: 0x567153cfa700
--]]
for k, v in pairs(clibs) do
    print(k, v)
end

--[[
1       userdata: 0x567153cf7fe0
2       userdata: 0x567153cfa700
--]]
for i, v in ipairs(clibs) do
    print(i, v)
end
  • registry["_PRELOAD"] is normally an empty table, but it can be used to store loaders for lua modules, and when require function load a new module, it will first search this table, so you can use it to do something interesting
package.preload["devtools"] = function()
    return { editor = "vim", os = "linux" }
end

local devtools = require "devtools"
print(devtools.editor, devtools.os)

environment variables

LUA_NOENV

  • if lua is used with -E option, then LUA_NOENV will be set true in registry
#!/usr/bin/lua5.4 -E

local registry = debug.getregistry()
print(registry["LUA_NOENV"])  -- true, since -E is used

LUA_PATH_5_4/LUA_PATH, LUA_CPATH_5_4/LUA_CPATH

  • ;; in envs LUA_PATH_5_4/LUA_PATH means to insert default value from macro LUA_PATH_DEFAULT to package.path‘s final value
  • ;; in envs LUA_CPATH_5_4/LUA_CPATH means to insert default value from macro LUA_CPATH_DEFAULT to package.cpath‘s final value
  • when lua is initialized, function luaopen_package will handle all these details
--[[ /usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua
--]]
print(package.path)

-- /usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so
print(package.cpath)

LUA_INIT_5_4/LUA_INIT

  • setup init env to do something before anything else
export LUA_INIT_5_4='@init.lua'   -- using `@` to load an init file

export LUA_INIT='print("hello world from init")'  -- init lua string

package table

package.config

  • a string describing some compile-time configurations for packages, please refer to lua manual for details
print(package.config)

package.loaded

  • package.loaded is a reference to registry["_LOADED"]
local registry = debug.getregistry()

--[[
table: 0x5c638d059a60
table: 0x5c638d059a60
--]]
print(registry["_LOADED"])
print(package.loaded)

package.preload

  • package.preload is a reference to registry["_PRELOAD"]
local registry = debug.getregistry()

--[[
table: 0x64a359eb7be0
table: 0x64a359eb7be0
--]]
print(registry["_PRELOAD"])
print(package.preload)

package.searchers

  • a table contains four searching functions (currently), which is used by require method to search modules, and they are searched in the following order
    • preload table
    • lua module, searching package.path one by one
    • c module, searching package.cpath one by one
    • c root module, still searching package.cpath, but build name by stripping components after the first dot ‘.’
  • using require "cjson.safe" as an example to illustrate the differences between searching c and searching c root
-- searching c: '/usr/local/lib/lua/5.4/`cjson/safe.so`', will try to find safe.so under cjson directory
-- searching c root: '/usr/local/lib/lua/5.4/`cjson.so`', will try to find cjson.so
local cjson = require "cjson.safe"
  • both of searching c and c root will finally look for function beginning with prefix luaopen_, such as ‘luaopen_cjson’ or ‘luaopen_cjson_safe’

package.searchpath

  • searching name in the given path, which is a template like package.path
    • dot ‘.’ in name will be replaced by ‘/‘
    • ‘?’ in path will be replaced by dot replaced name
local name = "foo.a"
local path = "./?.lua;./?.lc;/usr/local/?/init.lua"

-- so the search order will be: './foo/a.lua', './foo/a.lc', '/usr/local/foo/a/init.lua'
package.searchpath(name, path)

package.loadlib

  • it does not perform any path searching and does not automatically adds extensions like require, so libname arg must be the complete path
  • if funcname is “*” , then only loads and exports all symbols in the lib. otherwise looks for funcname and return it as a lua c function
  • libs loaded by this function will be added to registry["_CLIBS"] table
local cjson_path = "/usr/local/lib/lua/5.4/cjson.so"

print(package.loadlib(cjson_path, "*"))  -- true

-- error here, since "json_encode" is not exported by cjson.so, but hey this is just an example
local json_encode = package.loadlib(cjson_path, "json_encode")
-- using function following...

table module

table.concat

  • sep can be one char or more chars
local t = {"hello", "world"}
local r = table.concat(t, ",")

print(r)  -- "hello,world"

local t = {"hello", "world"}
local r = table.concat(t, "||")
print(r)  -- "hello||world"
  • can concat only part of the table
local t = {"prefix", "hello", "world", "postfix"}
local r = table.concat(t, ", ", 2, 3)
print(r)  -- "hello, world"

table.pack

  • all arguments are packed into a table, and n field is added with the total number of arguments
local t = table.pack("hello", "world", "you", "are", "great")
print(type(t))  -- table
print(t.n)   -- 4

--[[
1       hello
2       world
3       you're
4       great
--]]
for i, v in ipairs(t) do
    print(i, v)
end

table.unpack

  • unpack a table and return each separate element, can unpack a whole table or just a valid range of it
local t = {1, 2, 3, 4, 5}
print(table.unpack(t))  -- 1       2       3       4       5

local t = {"hello", "world", "you're", "great"}
local n1, n2 = table.unpack(t, 2, 3)
print(n1, n2)  -- world   you're

os module

os.time

  • current time if calling it without arguments
local now = os.time()
print(now)
  • the time represented by the table argument
-- other fields will be ignored
local date_tab = {
    year = 2024,    -- Required
    month = 1,      -- Required (1-12)
    day = 15,       -- Required (1-31)
    hour = 14,      -- Optional (0-23)
    min = 30,       -- Optional (0-59)
    sec = 45,       -- Optional (0-61, for leap seconds)
    isdst = false   -- Optional (daylight saving time)
}

local timepoint = os.time(date_tab)
print(timepoint)

os.date

  • current date string if calling it without any arguments
print(os.date())  -- Sun Dec 14 21:55:00 2025
  • If format is not *t, then date returns the date as a string, formatted according to the same rules as the ISO C function strftime
  • If format starts with ‘!’, then the date is formatted in UTC and if format is the string *t, then date returns a table with the following fields
local date_tab = os.date("!*t")
print(type(date_tab))  -- table
--[[
sec     11
yday    348
wday    1
min     3
month   12
hour    14
year    2025
isdst   false
day     14
--]]
for k, v in pairs(date_tab) do
    print(k, v)
end

os.execute

  • to test whether there is a shell, calling it without any arguments
local shell = os.execute()
print(shell)  -- true/false
  • execute a command, just a wrapper of the system library function
local ok, what, code = os.execute("ls")
print(ok, what, code)  -- true  exit  0

-- if the command was terminated by a signal, then the what string will be `signal`
local ok, what, code = os.execute("hello")
print(ok, what, code)  -- nil  exit  127

math module

math.pi

  • if you need to get pi‘s value
print(math.pi)  -- 3.1415926535898

math.huge

  • the float value HUGE_VAL, a value greater than any other numeric value
print(math.huge)  -- inf

math.maxinteger and math.mininteger

  • current platform’s maximum and minimum integer values
print(math.maxinteger)  -- 9223372036854775807

print(math.mininteger)  -- -9223372036854775808

math.max and math.min

  • as the name suggests, returning max and min value of arguments of each function. It would be better to combine with table.unpack function
local t = {2, 9, 1, 5, 7}

print(math.max(table.unpack(t)))  -- 9
print(math.min(table.unpack(t)))  -- 1

math.randomseed and math.random

  • calling math.randomseed without arguments for math.random to generate random number each time
math.randomseed()
print(math.random())  -- result will be different each time math.random is called
  • but calling math.randomseed with fixed arguments for math.random to generate the same number each time, this is good for testing only
math.randomseed(1, 10)
print(math.random())  -- result will be the same each time math.random is called

misc stuff

\z escape sequence

It skips the following span of whitespace characters, including line breaks; it is particularly useful to break and indent a long literal string into multiple lines without adding the newlines and spaces into the string contents

local s = "This is a very long string that \z
can be broken into multiple lines in the source \z
code for readability without adding actual newlines \z
to the string's content."

print(s)

local s = "\z     hello \z    world"
print(s)  -- 'hello world'

This article was updated on December 23, 2025