Ruby’s observable facilities are a hidden gem for developers looking to level up their reactive programming skills. I’ve been diving deep into this world lately, and I’m excited to share what I’ve learned.
At its core, the observable pattern in Ruby allows objects to notify other objects about changes in their state. It’s like setting up a bunch of spies in your code, always ready to report back when something interesting happens.
Let’s start with a simple example:
require 'observer'
class WeatherStation
include Observable
def temperature=(temp)
@temperature = temp
changed
notify_observers(temp)
end
end
class WeatherDisplay
def update(temperature)
puts "Current temperature: #{temperature}°C"
end
end
station = WeatherStation.new
display = WeatherDisplay.new
station.add_observer(display)
station.temperature = 25
In this code, we’ve created a WeatherStation that notifies its observers whenever the temperature changes. The WeatherDisplay is an observer that updates its display when notified.
But here’s where it gets interesting. The beauty of Ruby’s observable pattern is its flexibility. You’re not limited to just one type of observer or one type of change. You can have multiple observers reacting to different aspects of an object’s state.
I once worked on a project where we used this pattern to create a real-time dashboard for a busy restaurant kitchen. Every order, ingredient update, and staff change was an observable event. The result was a system that could adapt on the fly to the chaos of a bustling kitchen.
Let’s expand our weather example to show how this might work:
class WeatherStation
include Observable
attr_reader :temperature, :humidity, :pressure
def initialize
@temperature = 0
@humidity = 0
@pressure = 1000
end
def measure(temp, humidity, pressure)
@temperature = temp
@humidity = humidity
@pressure = pressure
changed
notify_observers(self)
end
end
class TemperatureDisplay
def update(station)
puts "Temperature: #{station.temperature}°C"
end
end
class HumidityDisplay
def update(station)
puts "Humidity: #{station.humidity}%"
end
end
class PressureDisplay
def update(station)
puts "Pressure: #{station.pressure} hPa"
end
end
station = WeatherStation.new
temp_display = TemperatureDisplay.new
humidity_display = HumidityDisplay.new
pressure_display = PressureDisplay.new
station.add_observer(temp_display)
station.add_observer(humidity_display)
station.add_observer(pressure_display)
station.measure(30, 65, 1013)
This setup allows different parts of our system to react to changes in the weather station independently. It’s a powerful way to decouple different components of your application.
But what if we want to get even more granular? What if we want to react only to specific types of changes? Ruby’s observable pattern doesn’t provide this out of the box, but we can extend it to do so:
module CustomObservable
def add_observer(observer, *attributes)
@observers ||= {}
attributes.each do |attr|
@observers[attr] ||= []
@observers[attr] << observer
end
end
def notify_observers(attribute)
return unless @observers && @observers[attribute]
@observers[attribute].each { |observer| observer.update(self) }
end
end
class WeatherStation
include CustomObservable
attr_reader :temperature, :humidity, :pressure
def temperature=(value)
@temperature = value
notify_observers(:temperature)
end
def humidity=(value)
@humidity = value
notify_observers(:humidity)
end
def pressure=(value)
@pressure = value
notify_observers(:pressure)
end
end
class TemperatureAlert
def update(station)
puts "ALERT: Temperature is now #{station.temperature}°C"
end
end
station = WeatherStation.new
temp_alert = TemperatureAlert.new
station.add_observer(temp_alert, :temperature)
station.temperature = 35 # This will trigger the alert
station.humidity = 80 # This won't trigger anything
This custom implementation allows observers to subscribe to specific attributes of the observable object. It’s a pattern I’ve found incredibly useful in complex systems where different components need to react to different types of changes.
But here’s a word of caution: while the observer pattern can be powerful, it can also lead to unexpected behavior if not used carefully. In complex systems, it can be hard to track the flow of updates, leading to bugs that are difficult to diagnose.
To mitigate this, I always recommend keeping your observable classes focused and your observer updates simple. If you find yourself with observers that are doing too much work or observable classes that are notifying about too many different types of changes, it might be time to refactor.
Another pitfall to watch out for is memory leaks. In Ruby, observers hold a reference to the observable object, which can prevent garbage collection if not managed properly. Always remember to remove observers when they’re no longer needed:
station.delete_observer(temp_alert)
Let’s look at a more complex example to see how we might use the observer pattern in a real-world scenario. Imagine we’re building a stock trading system:
class Stock
include Observable
attr_reader :symbol, :price
def initialize(symbol, price)
@symbol = symbol
@price = price
end
def price=(new_price)
return if new_price == @price
@price = new_price
changed
notify_observers(self)
end
end
class StockExchange
def initialize
@stocks = {}
end
def add_stock(stock)
@stocks[stock.symbol] = stock
end
def update_price(symbol, price)
@stocks[symbol].price = price if @stocks[symbol]
end
end
class Trader
def initialize(name)
@name = name
@portfolio = {}
end
def buy(stock)
@portfolio[stock.symbol] = stock
stock.add_observer(self)
end
def sell(stock_symbol)
if @portfolio[stock_symbol]
@portfolio[stock_symbol].delete_observer(self)
@portfolio.delete(stock_symbol)
end
end
def update(stock)
puts "#{@name}: #{stock.symbol} is now $#{stock.price}"
# Here we could add logic for automatic trading based on price changes
end
end
exchange = StockExchange.new
apple = Stock.new("AAPL", 150)
google = Stock.new("GOOGL", 2800)
exchange.add_stock(apple)
exchange.add_stock(google)
trader1 = Trader.new("Alice")
trader2 = Trader.new("Bob")
trader1.buy(apple)
trader1.buy(google)
trader2.buy(apple)
exchange.update_price("AAPL", 155)
exchange.update_price("GOOGL", 2850)
trader1.sell("AAPL")
exchange.update_price("AAPL", 160)
In this system, stocks are observable objects. Traders observe the stocks in their portfolio, automatically receiving updates when prices change. The stock exchange acts as a central point for updating stock prices.
This setup allows for a very flexible and extensible system. We could easily add new types of traders with different trading strategies, or new types of financial instruments, without having to modify the core logic of how updates are propagated.
One of the things I love about Ruby’s implementation of the observer pattern is how it encourages a clean separation of concerns. Observable objects don’t need to know anything about their observers, and observers don’t need to know about each other. This makes it easy to add new functionality without risking breaking existing code.
However, as our systems grow more complex, we might find that the simple observer pattern isn’t quite enough. For instance, what if we want to observe changes over time, or combine observations from multiple sources?
This is where we might start to look at more advanced reactive programming concepts. Libraries like RxRuby bring ideas from reactive extensions to Ruby, allowing for powerful transformations and combinations of observable streams.
Here’s a taste of what this might look like:
require 'rx'
stock_prices = Rx::Subject.new
# Simulate stock price updates
Thread.new do
loop do
stock_prices.on_next({ symbol: 'AAPL', price: rand(140..160) })
sleep(1)
end
end
# Calculate a moving average
moving_average = stock_prices.map { |stock| stock[:price] }
.window_with_count(5, 1)
.flat_map { |w| w.reduce(:+).map { |sum| sum / 5.0 } }
# Alert on significant price movements
price_alerts = stock_prices.zip(moving_average)
.map { |stock, avg| { stock: stock, difference: (stock[:price] - avg).abs } }
.filter { |data| data[:difference] > 5 }
subscription = price_alerts.subscribe(
on_next: ->(data) { puts "Alert: #{data[:stock][:symbol]} price of $#{data[:stock][:price]} is significantly different from 5-day moving average" },
on_error: ->(err) { puts "Error: #{err}" },
on_completed: -> { puts "Completed" }
)
# Let it run for a while
sleep(30)
# Clean up
subscription.dispose
This example shows how we can use reactive programming concepts to build more complex behaviors on top of our basic observables. We’re not just reacting to individual price changes anymore, but analyzing trends over time and alerting on specific conditions.
The beauty of this approach is that it’s declarative. We’re describing what we want to happen, not how to do it step by step. This can lead to code that’s easier to reason about and maintain, especially as the complexity of our system grows.
As we wrap up our exploration of Ruby’s observable facilities, I hope you’re starting to see the potential of this pattern. Whether you’re building a simple weather station or a complex financial system, the ability to create loosely coupled, event-driven components can be a game-changer.
Remember, though, that like any tool, observables should be used judiciously. They’re not a silver bullet, and in some cases, a simpler approach might be more appropriate. Always consider the specific needs of your project and the long-term maintainability of your code.
In my experience, the real power of observables comes not just from using them, but from combining them with other Ruby features and design patterns. Mix in some metaprogramming, sprinkle in some functional concepts, and you’ve got a recipe for incredibly flexible and powerful systems.
So go forth and observe! Experiment with these patterns in your own projects. Push the boundaries of what’s possible. And most importantly, have fun doing it. After all, that’s what Ruby is all about.