Lua - Modules & Package Management
Modules & Package Management
Time: ~35 minutes
Learn how to organize Lua code into reusable modules, understand the package system, and master dependency management.
Learning Objectives
- Understand Lua's module system and require function
- Create reusable modules with proper interfaces
- Master package path configuration and loading
- Apply modular programming best practices
- Handle module dependencies and circular references
Understanding require()
The require
function loads and executes Lua modules, returning their exported values.
Basic Module Loading
-- Loading built-in modules
local math = require("math")
local string = require("string")
local io = require("io")
print("Math PI:", math.pi)
print("String upper:", string.upper("hello"))
-- Loading user modules (assumes mymodule.lua exists)
-- local mymod = require("mymodule")
Expected Output:
Math PI: 3.1415926535898
String upper: HELLO
Module Search Path
-- Display package search paths
print("Lua search path:")
for path in string.gmatch(package.path, "[^;]+") do
print(" " .. path)
end
print("\nC library search path:")
for path in string.gmatch(package.cpath, "[^;]+") do
print(" " .. path)
end
-- Check if module is already loaded
print("\nLoaded packages:")
for name, module in pairs(package.loaded) do
if type(module) ~= "function" then
print(" " .. name)
end
end
Creating Simple Modules
Method 1: Return Table
-- File: calculator.lua
local calculator = {}
function calculator.add(a, b)
return a + b
end
function calculator.subtract(a, b)
return a - b
end
function calculator.multiply(a, b)
return a * b
end
function calculator.divide(a, b)
if b == 0 then
error("Division by zero")
end
return a / b
end
-- Private function (not exported)
local function validate_number(n)
return type(n) == "number"
end
function calculator.safe_add(a, b)
if not validate_number(a) or not validate_number(b) then
return nil, "Invalid numbers"
end
return a + b
end
return calculator
Using the Calculator Module
-- Simulate loading the module (normally would be require("calculator"))
local calculator = {
add = function(a, b) return a + b end,
subtract = function(a, b) return a - b end,
multiply = function(a, b) return a * b end,
divide = function(a, b)
if b == 0 then error("Division by zero") end
return a / b
end,
safe_add = function(a, b)
if type(a) ~= "number" or type(b) ~= "number" then
return nil, "Invalid numbers"
end
return a + b
end
}
-- Use the module
print("Addition:", calculator.add(10, 5))
print("Division:", calculator.divide(20, 4))
local result, err = calculator.safe_add("10", 5)
if not result then
print("Error:", err)
else
print("Safe add result:", result)
end
Expected Output:
Addition: 15
Division: 5
Error: Invalid numbers
Method 2: Direct Function Export
-- File: utils.lua
local utils = {}
-- String utilities
utils.trim = function(s)
return s:match("^%s*(.-)%s*$")
end
utils.split = function(s, delimiter)
local result = {}
for match in (s .. delimiter):gmatch("(.-)" .. delimiter) do
table.insert(result, match)
end
return result
end
-- Table utilities
utils.table_size = function(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
utils.deep_copy = function(t)
if type(t) ~= "table" then return t end
local copy = {}
for k, v in pairs(t) do
copy[k] = utils.deep_copy(v)
end
return copy
end
return utils
Using the Utils Module
-- Simulate the utils module
local utils = {
trim = function(s) return s:match("^%s*(.-)%s*$") end,
split = function(s, delimiter)
local result = {}
for match in (s .. delimiter):gmatch("(.-)" .. delimiter) do
table.insert(result, match)
end
return result
end,
table_size = function(t)
local count = 0
for _ in pairs(t) do count = count + 1 end
return count
end,
deep_copy = function(t)
if type(t) ~= "table" then return t end
local copy = {}
for k, v in pairs(t) do
copy[k] = utils.deep_copy(v)
end
return copy
end
}
-- Test string utilities
local text = " hello world "
print("Trimmed:", "'" .. utils.trim(text) .. "'")
local words = utils.split("apple,banana,cherry", ",")
print("Split result:")
for i, word in ipairs(words) do
print(" " .. i .. ": " .. word)
end
-- Test table utilities
local original = {a = 1, b = {c = 2, d = 3}}
local copy = utils.deep_copy(original)
copy.b.c = 999
print("Original b.c:", original.b.c)
print("Copy b.c:", copy.b.c)
print("Table size:", utils.table_size(original))
Expected Output:
Trimmed: 'hello world'
Split result:
1: apple
2: banana
3: cherry
Original b.c: 2
Copy b.c: 999
Table size: 2
Advanced Module Patterns
Module with Configuration
-- File: logger.lua
local logger = {}
-- Default configuration
local config = {
level = "INFO",
format = "[%s] %s: %s",
output = io.stdout
}
-- Log levels
local levels = {
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4
}
-- Configure the logger
function logger.configure(options)
for key, value in pairs(options) do
config[key] = value
end
end
-- Check if level should be logged
local function should_log(level)
return levels[level] >= levels[config.level]
end
-- Generic log function
local function log(level, message)
if should_log(level) then
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local formatted = string.format(config.format, timestamp, level, message)
config.output:write(formatted .. "\n")
config.output:flush()
end
end
-- Public logging functions
function logger.debug(message)
log("DEBUG", message)
end
function logger.info(message)
log("INFO", message)
end
function logger.warn(message)
log("WARN", message)
end
function logger.error(message)
log("ERROR", message)
end
return logger
Using the Logger Module
-- Simulate the logger module
local logger = {}
local config = {level = "INFO", format = "[%s] %s: %s"}
local levels = {DEBUG = 1, INFO = 2, WARN = 3, ERROR = 4}
function logger.configure(options)
for key, value in pairs(options) do
config[key] = value
end
end
local function should_log(level)
return levels[level] >= levels[config.level]
end
local function log(level, message)
if should_log(level) then
local timestamp = os.date("%H:%M:%S")
local formatted = string.format(config.format, timestamp, level, message)
print(formatted)
end
end
function logger.debug(message) log("DEBUG", message) end
function logger.info(message) log("INFO", message) end
function logger.warn(message) log("WARN", message) end
function logger.error(message) log("ERROR", message) end
-- Test the logger
logger.debug("This won't show (below INFO level)")
logger.info("Application started")
logger.warn("Low disk space")
logger.error("Connection failed")
-- Change configuration
logger.configure({level = "DEBUG"})
print("\n--- After changing to DEBUG level ---")
logger.debug("Now this will show")
logger.info("Debug mode enabled")
Expected Output:
[14:30:45] INFO: Application started
[14:30:45] WARN: Low disk space
[14:30:45] ERROR: Connection failed
--- After changing to DEBUG level ---
[14:30:45] DEBUG: Now this will show
[14:30:45] INFO: Debug mode enabled
Module with Class-like Interface
-- File: account.lua
local Account = {}
Account.__index = Account
-- Constructor
function Account.new(name, initial_balance)
local self = {
name = name or "Unknown",
balance = initial_balance or 0,
transactions = {}
}
return setmetatable(self, Account)
end
-- Methods
function Account:deposit(amount)
if amount <= 0 then
return false, "Amount must be positive"
end
self.balance = self.balance + amount
table.insert(self.transactions, {
type = "deposit",
amount = amount,
balance = self.balance,
timestamp = os.time()
})
return true
end
function Account:withdraw(amount)
if amount <= 0 then
return false, "Amount must be positive"
end
if amount > self.balance then
return false, "Insufficient funds"
end
self.balance = self.balance - amount
table.insert(self.transactions, {
type = "withdrawal",
amount = amount,
balance = self.balance,
timestamp = os.time()
})
return true
end
function Account:get_balance()
return self.balance
end
function Account:get_statement()
local statement = "Account: " .. self.name .. "\n"
statement = statement .. "Current Balance: $" .. self.balance .. "\n"
statement = statement .. "Recent Transactions:\n"
for _, trans in ipairs(self.transactions) do
statement = statement .. string.format(" %s: $%.2f (Balance: $%.2f)\n",
trans.type, trans.amount, trans.balance)
end
return statement
end
return Account
Using the Account Module
-- Simulate the Account module
local Account = {}
Account.__index = Account
function Account.new(name, initial_balance)
local self = {
name = name or "Unknown",
balance = initial_balance or 0,
transactions = {}
}
return setmetatable(self, Account)
end
function Account:deposit(amount)
if amount <= 0 then return false, "Amount must be positive" end
self.balance = self.balance + amount
table.insert(self.transactions, {
type = "deposit", amount = amount, balance = self.balance
})
return true
end
function Account:withdraw(amount)
if amount <= 0 then return false, "Amount must be positive" end
if amount > self.balance then return false, "Insufficient funds" end
self.balance = self.balance - amount
table.insert(self.transactions, {
type = "withdrawal", amount = amount, balance = self.balance
})
return true
end
function Account:get_balance() return self.balance end
function Account:get_statement()
local statement = "Account: " .. self.name .. "\nCurrent Balance: $" .. self.balance .. "\nRecent Transactions:\n"
for _, trans in ipairs(self.transactions) do
statement = statement .. string.format(" %s: $%.2f (Balance: $%.2f)\n",
trans.type, trans.amount, trans.balance)
end
return statement
end
-- Test the Account module
local account = Account.new("John Doe", 1000)
local success, err = account:deposit(500)
if success then
print("Deposit successful")
else
print("Deposit failed:", err)
end
local success, err = account:withdraw(200)
if success then
print("Withdrawal successful")
else
print("Withdrawal failed:", err)
end
print("Current balance:", account:get_balance())
print("\n" .. account:get_statement())
Expected Output:
Deposit successful
Withdrawal successful
Current balance: 1300
Account: John Doe
Current Balance: $1300
Recent Transactions:
deposit: $500.00 (Balance: $1500.00)
withdrawal: $200.00 (Balance: $1300.00)
Package Management
Module Preloading
-- Preload a module (useful for embedded modules)
package.preload["mymath"] = function()
local M = {}
function M.square(x) return x * x end
function M.cube(x) return x * x * x end
return M
end
-- Now we can require it
local mymath = require("mymath")
print("Square of 5:", mymath.square(5))
print("Cube of 3:", mymath.cube(3))
Expected Output:
Square of 5: 25
Cube of 3: 27
Custom Module Loader
-- Custom loader for configuration files
local function config_loader(name)
-- Remove "config." prefix
local config_name = name:match("^config%.(.+)")
if not config_name then
return nil
end
-- Simulate loading configuration
local configs = {
database = {
host = "localhost",
port = 5432,
database = "myapp",
username = "user"
},
server = {
port = 8080,
host = "0.0.0.0",
workers = 4
}
}
local config = configs[config_name]
if config then
return function() return config end
else
return nil, "Configuration not found: " .. config_name
end
end
-- Add custom loader
table.insert(package.searchers, config_loader)
-- Test custom loader
local db_config = require("config.database")
local server_config = require("config.server")
print("Database configuration:")
for key, value in pairs(db_config) do
print(" " .. key .. ":", value)
end
print("\nServer configuration:")
for key, value in pairs(server_config) do
print(" " .. key .. ":", value)
end
Expected Output:
Database configuration:
host: localhost
port: 5432
database: myapp
username: user
Server configuration:
port: 8080
host: 0.0.0.0
workers: 4
Best Practices
- Single responsibility: Each module should have one clear purpose
- Clear interface: Export only what needs to be public
- Documentation: Include usage examples and API documentation
- Error handling: Return meaningful error messages
- Configuration: Make modules configurable when appropriate
- Testing: Design modules to be easily testable
Architect Tip: Use module-level configuration and lazy loading for better performance. Consider using init functions for complex setup requirements.
Common Pitfalls
- Circular dependencies: Module A requires B, B requires A
- Global pollution: Accidentally creating global variables
- Path issues: Module not found due to incorrect paths
- Caching confusion: Modules are cached after first load
- Side effects: Module execution has unintended side effects
Checks for Understanding
- What does the require() function return?
- How do you create a private function in a module?
- What happens if you require the same module twice?
- How can you reload a module that has already been loaded?
Show Answers
- The value returned by the module (usually a table or function).
- Don't include it in the returned table; use local scope.
- The cached version is returned; module code runs only once.
- Set package.loaded[modulename] = nil, then require again.
Exercises
- Create a module for working with JSON-like data structures.
- Build a configuration management module that supports environment-specific settings.
- Design a plugin system using dynamic module loading.