Lua - Object-Oriented Programming

Object-Oriented Programming

Learn advanced object-oriented programming patterns in Lua using metatables, prototype-based inheritance, and modern OOP techniques for building scalable applications.

Estimated time: 35-40 minutes

Learning Objectives

  • Implement classes and objects using metatables
  • Create inheritance hierarchies with prototype patterns
  • Build polymorphic systems with method overriding
  • Design advanced OOP patterns and frameworks
  • Apply OOP principles to real-world applications

Basic Class Implementation

Lua doesn't have built-in classes, but we can simulate them using tables and metatables.

-- Simple class implementation
local Person = {}
Person.__index = Person

-- Constructor
function Person.new(name, age)
  local self = setmetatable({}, Person)
  self.name = name or "Unknown"
  self.age = age or 0
  return self
end

-- Instance methods
function Person:get_name()
  return self.name
end

function Person:set_name(name)
  self.name = name
end

function Person:get_age()
  return self.age
end

function Person:introduce()
  return string.format("Hi, I'm %s and I'm %d years old.", 
                      self.name, self.age)
end

function Person:have_birthday()
  self.age = self.age + 1
  print(self.name .. " is now " .. self.age .. " years old!")
end

-- Class method (static method)
function Person.get_species()
  return "Homo sapiens"
end

-- Demo basic class usage
local function demo_basic_class()
  print("=== Basic Class Demo ===")
  
  -- Create instances
  local alice = Person.new("Alice", 25)
  local bob = Person.new("Bob", 30)
  local anonymous = Person.new() -- uses defaults
  
  -- Use instance methods
  print(alice:introduce())
  print(bob:introduce())
  print(anonymous:introduce())
  
  -- Modify instances
  alice:have_birthday()
  bob:set_name("Robert")
  print(bob:introduce())
  
  -- Use class method
  print("Species:", Person.get_species())
  
  -- Check instance type
  print("Alice is a Person:", getmetatable(alice) == Person)
end

demo_basic_class()

Expected Output:

=== Basic Class Demo ===
Hi, I'm Alice and I'm 25 years old.
Hi, I'm Bob and I'm 30 years old.
Hi, I'm Unknown and I'm 0 years old.
Alice is now 26 years old!
Hi, I'm Robert and I'm 30 years old.
Species: Homo sapiens
Alice is a Person: true

Inheritance and Polymorphism

Create inheritance hierarchies with method overriding and polymorphic behavior.

-- Employee class inheriting from Person
local Employee = {}
Employee.__index = Employee
setmetatable(Employee, Person) -- Employee inherits from Person

-- Constructor
function Employee.new(name, age, job_title, salary)
  local self = Person.new(name, age) -- call parent constructor
  setmetatable(self, Employee)
  
  self.job_title = job_title or "Employee"
  self.salary = salary or 0
  
  return self
end

-- Override introduce method (polymorphism)
function Employee:introduce()
  return string.format("Hi, I'm %s, %d years old, and I work as a %s.", 
                      self.name, self.age, self.job_title)
end

-- New methods specific to Employee
function Employee:get_salary()
  return self.salary
end

function Employee:get_annual_salary()
  return self.salary * 12
end

function Employee:give_raise(amount)
  self.salary = self.salary + amount
  print(string.format("%s received a raise of $%.2f! New salary: $%.2f", 
                     self.name, amount, self.salary))
end

-- Manager class inheriting from Employee
local Manager = {}
Manager.__index = Manager
setmetatable(Manager, Employee) -- Manager inherits from Employee

function Manager.new(name, age, department, salary)
  local self = Employee.new(name, age, "Manager", salary)
  setmetatable(self, Manager)
  
  self.department = department or "General"
  self.subordinates = {}
  
  return self
end

-- Override introduce method
function Manager:introduce()
  return string.format("Hi, I'm %s, %d years old, and I manage the %s department.", 
                      self.name, self.age, self.department)
end

-- Manager-specific methods
function Manager:add_subordinate(employee)
  table.insert(self.subordinates, employee)
  print(string.format("%s now reports to %s", employee.name, self.name))
end

function Manager:get_team_size()
  return #self.subordinates
end

function Manager:hold_team_meeting()
  print(string.format("%s is holding a team meeting with %d employees:", 
                     self.name, #self.subordinates))
  for _, employee in ipairs(self.subordinates) do
    print("  - " .. employee.name .. " (" .. employee.job_title .. ")")
  end
end

-- Inheritance demo
local function demo_inheritance()
  print("\n=== Inheritance and Polymorphism Demo ===")
  
  -- Create instances of different classes
  local john = Person.new("John", 45)
  local sarah = Employee.new("Sarah", 28, "Developer", 5000)
  local mike = Manager.new("Mike", 35, "Engineering", 8000)
  
  -- Polymorphic behavior - same method, different implementations
  local people = {john, sarah, mike}
  
  print("Introductions:")
  for _, person in ipairs(people) do
    print("  " .. person:introduce())
  end
  
  -- Employee-specific functionality
  print("\nEmployee operations:")
  sarah:give_raise(500)
  print("Sarah's annual salary: $" .. sarah:get_annual_salary())
  
  -- Manager-specific functionality
  print("\nManager operations:")
  mike:add_subordinate(sarah)
  
  local dev2 = Employee.new("Tom", 26, "Junior Developer", 3500)
  mike:add_subordinate(dev2)
  
  mike:hold_team_meeting()
  print("Mike's team size:", mike:get_team_size())
  
  -- Check inheritance chain
  print("\nInheritance checking:")
  print("Sarah is Employee:", getmetatable(sarah) == Employee)
  print("Mike is Manager:", getmetatable(mike) == Manager)
  
  -- Method resolution - can call parent methods
  mike:have_birthday() -- From Person class
  print("Mike after birthday:", mike:get_age())
end

demo_inheritance()

Expected Output:

=== Inheritance and Polymorphism Demo ===
Introductions:
  Hi, I'm John and I'm 45 years old.
  Hi, I'm Sarah, 28 years old, and I work as a Developer.
  Hi, I'm Mike, 35 years old, and I manage the Engineering department.

Employee operations:
Sarah received a raise of $500.00! New salary: $5500.00
Sarah's annual salary: $66000

Manager operations:
Sarah now reports to Mike
Tom now reports to Mike
Mike is holding a team meeting with 2 employees:
  - Sarah (Developer)
  - Tom (Junior Developer)
Mike's team size: 2

Inheritance checking:
Sarah is Employee: true
Mike is Manager: true
Mike is now 36 years old!
Mike after birthday: 36

Advanced OOP Patterns

Implement sophisticated patterns like mixins, composition, and property systems.

-- Mixin pattern for shared behavior
local Serializable = {}

function Serializable:to_json()
  local function serialize_value(value)
    local value_type = type(value)
    if value_type == "string" then
      return '"' .. value:gsub('"', '\\"') .. '"'
    elseif value_type == "number" or value_type == "boolean" then
      return tostring(value)
    elseif value_type == "table" then
      local parts = {}
      for k, v in pairs(value) do
        if type(k) == "string" and not k:match("^__") then -- skip metamethods
          table.insert(parts, '"' .. k .. '":' .. serialize_value(v))
        end
      end
      return "{" .. table.concat(parts, ",") .. "}"
    else
      return "null"
    end
  end
  
  return serialize_value(self)
end

function Serializable:from_json(json_str)
  -- Simplified JSON parser (for demo purposes)
  -- In real code, use proper JSON library
  local data = load("return " .. json_str:gsub('"([^"]+)":', '["%1"]='))()
  for k, v in pairs(data) do
    self[k] = v
  end
  return self
end

-- Timestampable mixin
local Timestampable = {}

function Timestampable:set_created()
  self.created_at = os.time()
end

function Timestampable:set_updated()
  self.updated_at = os.time()
end

function Timestampable:get_age()
  if self.created_at then
    return os.time() - self.created_at
  end
  return 0
end

-- Function to add mixin to class
local function add_mixin(class, mixin)
  for name, method in pairs(mixin) do
    if type(method) == "function" then
      class[name] = method
    end
  end
end

-- Product class with mixins
local Product = {}
Product.__index = Product

function Product.new(name, price, category)
  local self = setmetatable({}, Product)
  self.name = name or "Unknown Product"
  self.price = price or 0
  self.category = category or "General"
  self:set_created() -- from Timestampable mixin
  return self
end

function Product:get_info()
  return string.format("%s - $%.2f (%s)", self.name, self.price, self.category)
end

function Product:apply_discount(percentage)
  local discount = self.price * (percentage / 100)
  self.price = self.price - discount
  self:set_updated() -- from Timestampable mixin
  return discount
end

-- Add mixins to Product class
add_mixin(Product, Serializable)
add_mixin(Product, Timestampable)

-- Property system implementation
local function create_property(getter, setter, validator)
  return {
    get = getter,
    set = setter,
    validate = validator
  }
end

-- Advanced class with property system
local BankAccount = {}
BankAccount.__index = BankAccount

function BankAccount.new(account_number, initial_balance)
  local self = setmetatable({}, BankAccount)
  self._account_number = account_number
  self._balance = initial_balance or 0
  self._transaction_history = {}
  self:set_created()
  return self
end

-- Property definitions
BankAccount.properties = {
  balance = create_property(
    function(self) return self._balance end,
    function(self, value) 
      if value < 0 then
        error("Balance cannot be negative")
      end
      self._balance = value 
    end,
    function(value) return type(value) == "number" and value >= 0 end
  ),
  
  account_number = create_property(
    function(self) return self._account_number end,
    function(self, value) 
      error("Account number is read-only") 
    end,
    function(value) return type(value) == "string" end
  )
}

-- Property access metamethods
function BankAccount:__index(key)
  local property = self.properties and self.properties[key]
  if property and property.get then
    return property.get(self)
  end
  return BankAccount[key]
end

function BankAccount:__newindex(key, value)
  local property = self.properties and self.properties[key]
  if property then
    if property.validate and not property.validate(value) then
      error("Invalid value for property " .. key)
    end
    if property.set then
      property.set(self, value)
    else
      error("Property " .. key .. " is read-only")
    end
  else
    rawset(self, key, value)
  end
end

-- Account methods
function BankAccount:deposit(amount)
  if amount <= 0 then
    error("Deposit amount must be positive")
  end
  
  self._balance = self._balance + amount
  table.insert(self._transaction_history, {
    type = "deposit",
    amount = amount,
    timestamp = os.time(),
    balance_after = self._balance
  })
  
  return self._balance
end

function BankAccount:withdraw(amount)
  if amount <= 0 then
    error("Withdrawal amount must be positive")
  end
  
  if amount > self._balance then
    error("Insufficient funds")
  end
  
  self._balance = self._balance - amount
  table.insert(self._transaction_history, {
    type = "withdrawal",
    amount = amount,
    timestamp = os.time(),
    balance_after = self._balance
  })
  
  return self._balance
end

function BankAccount:get_statement()
  local statement = {
    account_number = self._account_number,
    current_balance = self._balance,
    transactions = {}
  }
  
  for _, transaction in ipairs(self._transaction_history) do
    table.insert(statement.transactions, {
      type = transaction.type,
      amount = transaction.amount,
      date = os.date("%Y-%m-%d %H:%M:%S", transaction.timestamp),
      balance_after = transaction.balance_after
    })
  end
  
  return statement
end

-- Add mixins
add_mixin(BankAccount, Serializable)
add_mixin(BankAccount, Timestampable)

-- Demo advanced patterns
local function demo_advanced_patterns()
  print("\n=== Advanced OOP Patterns Demo ===")
  
  -- Mixin demo
  print("--- Mixin Pattern ---")
  local laptop = Product.new("Gaming Laptop", 1200, "Electronics")
  print("Product info:", laptop:get_info())
  
  local discount = laptop:apply_discount(10)
  print("Applied 10% discount: $" .. string.format("%.2f", discount))
  print("New price:", laptop:get_info())
  
  -- Serialization mixin
  local json = laptop:to_json()
  print("JSON representation:", json)
  
  -- Property system demo
  print("\n--- Property System ---")
  local account = BankAccount.new("ACC-12345", 1000)
  
  -- Property access
  print("Account number:", account.account_number)
  print("Initial balance:", account.balance)
  
  -- Account operations
  account:deposit(500)
  print("After deposit: $" .. account.balance)
  
  account:withdraw(200)
  print("After withdrawal: $" .. account.balance)
  
  -- Try to modify read-only property (will error)
  local success, err = pcall(function()
    account.account_number = "HACKED"
  end)
  if not success then
    print("Property protection worked:", err)
  end
  
  -- Try to set negative balance (will error)
  success, err = pcall(function()
    account.balance = -100
  end)
  if not success then
    print("Validation worked:", err)
  end
  
  -- Account statement
  local statement = account:get_statement()
  print("\nAccount Statement:")
  print("Account:", statement.account_number)
  print("Current Balance: $" .. statement.current_balance)
  print("Transaction History:")
  for _, tx in ipairs(statement.transactions) do
    print(string.format("  %s: %s $%.2f (Balance: $%.2f)", 
                       tx.date, tx.type, tx.amount, tx.balance_after))
  end
end

demo_advanced_patterns()

Expected Output:

=== Advanced OOP Patterns Demo ===
--- Mixin Pattern ---
Product info: Gaming Laptop - $1200.00 (Electronics)
Applied 10% discount: $120.00
New price: Gaming Laptop - $1080.00 (Electronics)
JSON representation: {"name":"Gaming Laptop","price":1080,"category":"Electronics","created_at":1642265445}

--- Property System ---
Account number: ACC-12345
Initial balance: 1000
After deposit: $1500
After withdrawal: $1300
Property protection worked: Account number is read-only
Validation worked: Balance cannot be negative

Account Statement:
Account: ACC-12345
Current Balance: $1300
Transaction History:
  2024-01-15 14:30:45: deposit $500.00 (Balance: $1500.00)
  2024-01-15 14:30:45: withdrawal $200.00 (Balance: $1300.00)

Design Patterns in Lua OOP

Implement common design patterns using Lua's OOP capabilities.

-- Observer Pattern
local Observable = {}
Observable.__index = Observable

function Observable.new()
  local self = setmetatable({}, Observable)
  self.observers = {}
  return self
end

function Observable:add_observer(observer)
  table.insert(self.observers, observer)
end

function Observable:remove_observer(observer)
  for i, obs in ipairs(self.observers) do
    if obs == observer then
      table.remove(self.observers, i)
      break
    end
  end
end

function Observable:notify_observers(event, data)
  for _, observer in ipairs(self.observers) do
    if observer.on_notify then
      observer:on_notify(event, data)
    end
  end
end

-- Stock price tracker using Observer pattern
local StockTracker = {}
StockTracker.__index = StockTracker
setmetatable(StockTracker, Observable)

function StockTracker.new(symbol)
  local self = Observable.new()
  setmetatable(self, StockTracker)
  self.symbol = symbol
  self.price = 0
  self.history = {}
  return self
end

function StockTracker:update_price(new_price)
  local old_price = self.price
  self.price = new_price
  
  table.insert(self.history, {
    price = new_price,
    timestamp = os.time()
  })
  
  -- Notify observers
  self:notify_observers("price_change", {
    symbol = self.symbol,
    old_price = old_price,
    new_price = new_price,
    change = new_price - old_price
  })
end

-- Observer implementations
local EmailAlert = {}
EmailAlert.__index = EmailAlert

function EmailAlert.new(email, threshold)
  local self = setmetatable({}, EmailAlert)
  self.email = email
  self.threshold = threshold or 0
  return self
end

function EmailAlert:on_notify(event, data)
  if event == "price_change" and math.abs(data.change) >= self.threshold then
    print(string.format("EMAIL ALERT to %s: %s price changed by $%.2f to $%.2f", 
                       self.email, data.symbol, data.change, data.new_price))
  end
end

local Dashboard = {}
Dashboard.__index = Dashboard

function Dashboard.new()
  local self = setmetatable({}, Dashboard)
  self.stocks = {}
  return self
end

function Dashboard:on_notify(event, data)
  if event == "price_change" then
    self.stocks[data.symbol] = data.new_price
    print(string.format("DASHBOARD UPDATE: %s = $%.2f (%.2f%%)", 
                       data.symbol, data.new_price, 
                       data.old_price > 0 and (data.change/data.old_price)*100 or 0))
  end
end

-- Strategy Pattern for different trading strategies
local TradingStrategy = {}

-- Conservative strategy
local ConservativeStrategy = {}
ConservativeStrategy.__index = ConservativeStrategy

function ConservativeStrategy.new()
  return setmetatable({}, ConservativeStrategy)
end

function ConservativeStrategy:should_buy(price, moving_average)
  return price < moving_average * 0.95 -- Buy when 5% below average
end

function ConservativeStrategy:should_sell(price, moving_average)
  return price > moving_average * 1.10 -- Sell when 10% above average
end

-- Aggressive strategy
local AggressiveStrategy = {}
AggressiveStrategy.__index = AggressiveStrategy

function AggressiveStrategy.new()
  return setmetatable({}, AggressiveStrategy)
end

function AggressiveStrategy:should_buy(price, moving_average)
  return price < moving_average * 0.98 -- Buy when 2% below average
end

function AggressiveStrategy:should_sell(price, moving_average)
  return price > moving_average * 1.05 -- Sell when 5% above average
end

-- Trading bot using Strategy pattern
local TradingBot = {}
TradingBot.__index = TradingBot

function TradingBot.new(name, strategy, initial_cash)
  local self = setmetatable({}, TradingBot)
  self.name = name
  self.strategy = strategy
  self.cash = initial_cash or 10000
  self.portfolio = {}
  self.price_history = {}
  return self
end

function TradingBot:on_notify(event, data)
  if event == "price_change" then
    local symbol = data.symbol
    local price = data.new_price
    
    -- Update price history
    if not self.price_history[symbol] then
      self.price_history[symbol] = {}
    end
    table.insert(self.price_history[symbol], price)
    
    -- Keep only last 10 prices for moving average
    if #self.price_history[symbol] > 10 then
      table.remove(self.price_history[symbol], 1)
    end
    
    -- Calculate moving average
    local sum = 0
    for _, p in ipairs(self.price_history[symbol]) do
      sum = sum + p
    end
    local moving_average = sum / #self.price_history[symbol]
    
    -- Make trading decisions
    local shares_owned = self.portfolio[symbol] or 0
    
    if self.strategy:should_buy(price, moving_average) and self.cash >= price then
      local shares_to_buy = math.floor(self.cash / (price * 2)) -- Conservative position sizing
      if shares_to_buy > 0 then
        self.cash = self.cash - (shares_to_buy * price)
        self.portfolio[symbol] = shares_owned + shares_to_buy
        print(string.format("%s BUY: %d shares of %s at $%.2f", 
                           self.name, shares_to_buy, symbol, price))
      end
    elseif self.strategy:should_sell(price, moving_average) and shares_owned > 0 then
      local shares_to_sell = math.floor(shares_owned / 2) -- Sell half
      if shares_to_sell > 0 then
        self.cash = self.cash + (shares_to_sell * price)
        self.portfolio[symbol] = shares_owned - shares_to_sell
        print(string.format("%s SELL: %d shares of %s at $%.2f", 
                           self.name, shares_to_sell, symbol, price))
      end
    end
  end
end

function TradingBot:get_portfolio_value(current_prices)
  local total_value = self.cash
  for symbol, shares in pairs(self.portfolio) do
    if current_prices[symbol] then
      total_value = total_value + (shares * current_prices[symbol])
    end
  end
  return total_value
end

-- Demo design patterns
local function demo_design_patterns()
  print("\n=== Design Patterns Demo ===")
  
  -- Create stock tracker
  local apple_stock = StockTracker.new("AAPL")
  
  -- Create observers
  local email_alert = EmailAlert.new("[email protected]", 5.0)
  local dashboard = Dashboard.new()
  local conservative_bot = TradingBot.new("ConservativeBot", ConservativeStrategy.new(), 10000)
  local aggressive_bot = TradingBot.new("AggressiveBot", AggressiveStrategy.new(), 10000)
  
  -- Register observers
  apple_stock:add_observer(email_alert)
  apple_stock:add_observer(dashboard)
  apple_stock:add_observer(conservative_bot)
  apple_stock:add_observer(aggressive_bot)
  
  -- Simulate price changes
  local prices = {150, 145, 140, 155, 160, 165, 158, 152, 148, 162, 170}
  
  print("Simulating AAPL price changes:")
  for _, price in ipairs(prices) do
    apple_stock:update_price(price)
    -- Brief pause for readability
    print("---")
  end
  
  -- Show final portfolio values
  local current_prices = {AAPL = 170}
  print(string.format("\nFinal Portfolio Values:"))
  print(string.format("Conservative Bot: $%.2f", 
                     conservative_bot:get_portfolio_value(current_prices)))
  print(string.format("Aggressive Bot: $%.2f", 
                     aggressive_bot:get_portfolio_value(current_prices)))
end

demo_design_patterns()

Expected Output:

=== Design Patterns Demo ===
Simulating AAPL price changes:
DASHBOARD UPDATE: AAPL = $150.00 (0.00%)
---
DASHBOARD UPDATE: AAPL = $145.00 (-3.33%)
---
DASHBOARD UPDATE: AAPL = $140.00 (-3.45%)
ConservativeBot BUY: 35 shares of AAPL at $140.00
AggressiveBot BUY: 35 shares of AAPL at $140.00
DASHBOARD UPDATE: AAPL = $155.00 (10.71%)
---
DASHBOARD UPDATE: AAPL = $160.00 (3.23%)
ConservativeBot SELL: 17 shares of AAPL at $160.00
---
...
Final Portfolio Values:
Conservative Bot: $10847.50
Aggressive Bot: $10925.00

Common Pitfalls

OOP Best Practices in Lua

  • Memory management: Be careful with circular references in object hierarchies
  • Method calls: Use colon syntax : for methods that need self
  • Inheritance depth: Avoid deep inheritance chains that can impact performance
  • Metatable sharing: Don't share metatables between unrelated objects
  • Constructor patterns: Always return the new instance from constructors

Checks for Understanding

  1. What's the difference between . and : method calls?
  2. How does inheritance work with metatables in Lua?
  3. What are the benefits of using mixins over inheritance?
  4. How can you implement private/protected members in Lua classes?
Show answers
  1. : automatically passes self as first parameter, . doesn't
  2. Set the class's metatable to the parent class for method resolution
  3. Mixins provide multiple inheritance and composition without complex hierarchies
  4. Use closures or naming conventions (underscore prefix) for encapsulation

Exercises

  1. Create a game engine with Entity-Component-System architecture
  2. Implement a plugin system using dynamic class loading
  3. Build a form validation framework with chainable rules