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

  1. What does the require() function return?
  2. How do you create a private function in a module?
  3. What happens if you require the same module twice?
  4. How can you reload a module that has already been loaded?
Show Answers
  1. The value returned by the module (usually a table or function).
  2. Don't include it in the returned table; use local scope.
  3. The cached version is returned; module code runs only once.
  4. Set package.loaded[modulename] = nil, then require again.

Exercises

  1. Create a module for working with JSON-like data structures.
  2. Build a configuration management module that supports environment-specific settings.
  3. Design a plugin system using dynamic module loading.