Skip to content

DockYard/gen_object

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GenObject

"I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning -- it took a while to see how to do messaging in a programming language efficiently enough to be useful)."

— Alan Kay

A library for creating stateful objects backed by GenServer processes with inheritance support.

Elixir's process isolation perfectly embodies Alan Kay's original vision of object-oriented programming. Each process is truly isolated with its own memory space, can only communicate through messages, and fails independently without affecting other processes. GenObject builds on this foundation to provide a clean, object-like interface over GenServer processes, making it easy to create stateful objects that communicate through message passing while maintaining the fault-tolerance and concurrency benefits of the Actor model.

GenObject provides a macro-based DSL for defining object-like structures that maintain state in GenServer processes. Objects support field access, updates, lazy operations, and merging. The library uses the Inherit library to provide powerful inheritance modeling capabilities.

Performance Overhead Warning {: .warning}

GenObjects have significant performance overhead compared to using Elixir's native data structures directly. Each GenObject is backed by a GenServer process, which means every field access, update, or merge operation involves message passing and serialization. This is much slower than working with structs, maps, and other Elixir literals in memory.

GenObjects are best suited for specific patterns where you need long-lived stateful objects, process isolation, fault tolerance, concurrent access to shared state, or complex inheritance hierarchies.

Do not use GenObjects as a general replacement for Elixir's built-in data structures in your application. Use them judiciously where the benefits of process isolation and message passing outweigh the performance costs.

Features

  • Stateful Objects: Objects backed by GenServer processes with automatic lifecycle management
  • Field Operations: get, set, and merge operations with both synchronous and asynchronous variants
  • Lazy Operations: Functions that compute values based on current object state
  • Inheritance Support: Inherit library for object inheritance patterns
  • Process Safety: All operations are process-safe through GenServer messaging
  • Performance: Asynchronous variants for high-performance scenarios

Installation

Add gen_object to your list of dependencies in mix.exs:

def deps do
  [
    {:gen_object, "~> 0.4.0"}
  ]
end

Quick Start

Basic Object Definition

defmodule Person do
  use GenObject, [
    name: "",
    age: nil,
    email: nil
  ]
end

# Create a new person object
person = Person.new(name: "Alice", age: 30)
# Returns: %Person{name: "Alice", age: 30, email: nil, pid: #PID<...>}

Field Access

# Get a single field (most efficient)
name = Person.get(person, :name)
# Returns: "Alice"

# Get multiple fields at once
[name, age] = Person.get(person, [:name, :age])
# Returns: ["Alice", 30]

# Can also use PID directly
age = Person.get(person.pid, :age)
# Returns: 30

# Get the complete object (when you need all fields)
current_person = Person.get(person)
# Returns: %Person{name: "Alice", age: 30, email: nil, pid: #PID<...>}

Field Updates

# Synchronous update (returns updated object)
person = Person.set(person, :age, 31)
person = Person.set(person.pid, :email, "[email protected]")

# Asynchronous update (returns :ok immediately)
:ok = Person.set!(person, :age, 32)
:ok = Person.set!(person.pid, :name, "Alice Smith")

# Verify async updates
age = Person.get(person, :age)
name = Person.get(person, :name)

Lazy Updates

Lazy updates allow you to compute new values based on the current object state:

# Increment age based on current value
person = Person.set_lazy(person, :age, fn p -> p.age + 1 end)

# Create display name from existing fields
person = Person.set_lazy(person, :display_name, fn p ->
  "#{p.name} (#{p.age})"
end)

# Asynchronous lazy update
:ok = Person.set_lazy!(person, :age, fn p -> p.age + 1 end)

Merging Multiple Fields

# Synchronous merge
person = Person.merge(person, %{
  name: "Alice Johnson",
  age: 35,
  email: "[email protected]",
  location: "San Francisco"
})

# Asynchronous merge
:ok = Person.merge!(person, %{age: 36, location: "New York"})

# Lazy merge based on current state
person = Person.merge_lazy(person, fn p ->
  age_group = if p.age < 18, do: "minor", else: "adult"
  %{
    age_group: age_group,
    can_vote: p.age >= 18,
    display_name: "#{p.name} (#{age_group})"
  }
end)

Inheritance with the Inherit Library

GenObject uses the Inherit library to provide powerful inheritance modeling:

Basic Inheritance

defmodule Animal do
  use GenObject, [
    name: "",
    species: "",
    age: 0
  ]
  
  def speak(%__MODULE__{} = animal) do
    "#{animal.name} makes a sound"
  end
end

defmodule Dog do
  use Animal, [
    breed: "",
    trained: false
  ]
  
  # Override parent method
  def speak(%__MODULE__{} = dog) do
    "#{dog.name} barks! Woof!"
  end
  
  def sit(%__MODULE__{trained: true} = dog) do
    Dog.set(dog, :position, :sitting)
  end
  
  def sit(%__MODULE__{trained: false} = dog) do
    {:error, "#{dog.name} is not trained to sit"}
  end
end

# Dog inherits all fields from Animal plus its own
dog = Dog.new(
  name: "Rex", 
  species: "Canis lupus", 
  breed: "Labrador", 
  age: 3,
  trained: true
)

# Use inherited and own methods
Dog.speak(dog)  # "Rex barks! Woof!"
Dog.sit(dog)    # Updates position to :sitting

Multi-level Inheritance

defmodule LivingThing do
  use GenObject, [
    alive: true,
    birth_date: nil
  ]
end

defmodule Animal do
  use LivingThing, [
    name: "",
    species: ""
  ]
end

defmodule Mammal do
  use Animal, [
    warm_blooded: true,
    fur_color: nil
  ]
end

defmodule Dog do
  use Mammal, [
    breed: "",
    trained: false
  ]
end

# Dog inherits from the entire chain
dog = Dog.new(
  name: "Buddy",
  species: "Canis lupus",
  breed: "Golden Retriever",
  fur_color: "golden",
  birth_date: ~D[2020-01-15],
  trained: true
)

Complex Inheritance Patterns

defmodule Vehicle do
  use GenObject, [
    make: "",
    model: "",
    year: nil,
    mileage: 0
  ]
  
  def drive(%__MODULE__{} = vehicle, distance) do
    Vehicle.set_lazy(vehicle, :mileage, fn v -> v.mileage + distance end)
  end
end

defmodule Car do
  use Vehicle, [
    doors: 4,
    fuel_type: :gasoline
  ]
  
  def honk(%__MODULE__{} = car) do
    "#{car.make} #{car.model} honks: BEEP BEEP!"
  end
end

defmodule ElectricCar do
  use Car, [
    battery_capacity: 0,
    charge_level: 100,
    fuel_type: :electric  # Override parent default
  ]
  
  def charge(%__MODULE__{} = car, amount) do
    ElectricCar.set_lazy(car, :charge_level, fn c ->
      min(100, c.charge_level + amount)
    end)
  end
  
  # Override parent method
  def drive(%__MODULE__{} = car, distance) do
    car = super(car, distance)  # Call parent implementation
    # Reduce charge based on distance
    ElectricCar.set_lazy(car, :charge_level, fn c ->
      max(0, c.charge_level - div(distance, 10))
    end)
  end
end

# Create an electric car with full inheritance chain
tesla = ElectricCar.new(
  make: "Tesla",
  model: "Model 3",
  year: 2023,
  battery_capacity: 75,
  doors: 4
)

# Use methods from all levels of inheritance
tesla = ElectricCar.drive(tesla, 100)    # Inherited and overridden
tesla = ElectricCar.charge(tesla, 20)    # Own method
message = Car.honk(tesla)                # Parent method

Virtual Attributes

GenObject supports virtual attributes - computed fields that don't store data directly but calculate values dynamically or transform input into multiple real fields. Virtual attributes are implemented by overriding the handle_get/2, handle_set/2, and handle_merge/2 callbacks.

Computed Virtual Attributes

Virtual attributes that calculate values from existing fields:

defmodule Person do
  use GenObject, [
    first_name: "",
    last_name: "",
    age: nil
  ]
  
  # Virtual attribute that computes full name from parts
  def handle_get(:name, %Person{} = person) do
    "#{person.first_name} #{person.last_name}"
  end
  
  # Fall back to default behavior for regular fields
  def handle_get(field, object) do
    super(field, object)
  end
end

person = Person.new(first_name: "Alice", last_name: "Smith")
Person.get(person, :name)  # Returns: "Alice Smith"

# Get multiple fields including virtual attributes
[name, first_name, last_name] = Person.get(person, [:name, :first_name, :last_name])
# Returns: ["Alice Smith", "Alice", "Smith"]

Input-Transforming Virtual Attributes

Virtual attributes that parse input and set multiple real fields:

defmodule Person do
  use GenObject, [
    first_name: "",
    last_name: "",
    age: nil
  ]
  
  # Virtual attribute that splits full name into parts
  def handle_set({:name, full_name}, %Person{} = person) do
    [first_name, last_name] = String.split(full_name, " ", parts: 2)
    Map.merge(person, %{first_name: first_name, last_name: last_name})
  end

  # Fall back to default behavior for other operations
  def handle_set(pair, object), do: super(pair, object)
  
  # Computed virtual attribute (from above)
  def handle_get(:name, %Person{} = person) do
    "#{person.first_name} #{person.last_name}"
  end
  
  # Fall back to default behavior for other operations
  def handle_get(field, object), do: super(field, object)
end

person = Person.new()
person = Person.set(person, :name, "Bob Johnson")

[first_name, last_name, name] = Person.get(person, [:first_name, :last_name, :name])
# Returns: ["Bob", "Johnson", "Bob Johnson"]

# Virtual attributes work with merge operations too
person = Person.merge(person, %{name: "Carol Davis", age: 25})
[first_name, last_name] = Person.get(person, [:first_name, :last_name])
# Returns: ["Carol", "Davis"]

Advanced Virtual Attribute Patterns

You can create more complex virtual attributes for validation, formatting, or derived data:

defmodule BankAccount do
  use GenObject, [
    balance_cents: 0,
    account_number: "",
    routing_number: ""
  ]
  
  # Virtual attribute for balance in dollars
  def handle_get(:balance, %BankAccount{} = account) do
    account.balance_cents / 100
  end
  
  # Virtual attribute for formatted account info
  def handle_get(:account_info, %BankAccount{} = account) do
    "Account: #{account.account_number} (Routing: #{account.routing_number})"
  end

  def handle_get(field, object), do: super(field, object)
  
  def handle_set({:balance, dollars}, %BankAccount{} = account) do
    cents = round(dollars * 100)
    Map.put(account, :balance_cents, cents)
  end
  
  # Validation virtual attribute
  def handle_set({:account_details, details}, %BankAccount{} = account) do
    %{account_number: acct, routing_number: routing} = details
    
    unless valid_account_number?(acct) do
      raise ArgumentError, "Invalid account number"
    end
    
    Map.merge(account, %{account_number: acct, routing_number: routing})
  end
  
  def handle_set(pair, object), do: super(pair, object)
  
  defp valid_account_number?(number) do
    String.length(number) >= 8
  end
end

account = BankAccount.new(balance_cents: 10050)
balance = BankAccount.get(account, :balance)  # Returns: 100.5

account = BankAccount.set(account, :balance, 250.75)
[balance, balance_cents] = BankAccount.get(account, [:balance, :balance_cents])
# Returns: [250.75, 25075]

Advanced Usage

Custom GenServer Callbacks

You can override GenServer callbacks while still using GenObject functionality:

defmodule TimestampedObject do
  use GenObject, [
    data: nil,
    created_at: nil,
    updated_at: nil
  ]
  
  # Override init to set timestamps
  def init(opts) do
    now = DateTime.utc_now()
    opts = opts
    |> Keyword.set(:created_at, now)
    |> Keyword.set(:updated_at, now)
    
    super(opts)  # Call GenObject's init
  end
  
  # Override handle_call to update timestamps
  def handle_call({:set, field, value}, from, object) do
    result = super({:set, field, value}, from, object)
    case result do
      {:reply, updated_object, state} ->
        updated_state = TimestampedObject.set(state, :updated_at, DateTime.utc_now())
        {:reply, updated_object, updated_state}
      other -> other
    end
  end
end

Supervision

GenObjects can be supervised like any GenServer:

defmodule MyApp.ObjectSupervisor do
  use Supervisor
  
  def start_link(_opts) do
    Supervisor.start_link(__MODULE__, [], name: __MODULE__)
  end
  
  def init([]) do
    children = [
      {Person, [name: "Default Person"]},
      {Dog, [name: "Default Dog", breed: "Mixed"]}
    ]
    
    Supervisor.init(children, strategy: :one_for_one)
  end
end

Performance Considerations

  • Use asynchronous operations (set!/3, merge!/2, etc.) when you don't need the return value
  • Use get/2 for single fields instead of get/1 when you only need one field
  • Batch multiple updates using merge/2 instead of multiple set/3 calls
  • Lazy operations are computed synchronously, so complex computations may block

API Reference

Creation and Lifecycle

  • YourModule.new/1 - Create a new object
  • YourModule.close/1 - Stop the object process

Field Access

  • YourModule.get/1 - Get complete object state
  • YourModule.get/2 - Get specific field value or multiple field values

Field Updates

  • YourModule.set/3 - Update field synchronously
  • YourModule.set!/3 - Update field asynchronously
  • YourModule.set_lazy/3 - Update field with function synchronously
  • YourModule.set_lazy!/3 - Update field with function asynchronously

Bulk Operations

  • YourModule.merge/2 - Merge multiple fields synchronously
  • YourModule.merge!/2 - Merge multiple fields asynchronously
  • YourModule.merge_lazy/2 - Merge with function synchronously
  • YourModule.merge_lazy!/2 - Merge with function asynchronously

All functions accept either a PID or an object struct containing a :pid field. Replace YourModule with your actual module name (e.g., Person, Dog, etc.).

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

This project is licensed under the MIT License - see the LICENSE.md file for details.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages