Skip to main content

Classes and Modules

This guide explains how to define and structure your code using Ruby's object model, including modules, classes, and inheritance.

Defining Modules

Key Points

  • Use modules to namespace related functionality.
  • Define constants and module-level methods.
  • Use static variables with Atomic types to maintain thread-safe state within a module.

Example: Module with State

This example creates an Lz4Flex module that uses Lazy static variables to safely initialize shared data like the module itself and custom error classes. This is a common and robust pattern for managing global state in a Rust extension.

use magnus::{
function, prelude::*, value::{InnerValue, Lazy}, Error, ExceptionClass, RModule,
Ruby,
};

// Using a `Lazy` static to define a module is a great way to ensure that
// the module is only defined once, and that it is thread-safe.
static LZ4_FLEX: Lazy<RModule> =
Lazy::new(|ruby| match ruby.define_module("Lz4Flex") {
Ok(m) => m,
Err(e) => panic!("Failed to define Lz4Flex module: {}", e),
});

// We can also use `Lazy` to define error classes, which can be used
// across our extension.
fn base_error_class() -> ExceptionClass {
static BASE_ERROR_CLASS: Lazy<ExceptionClass> = Lazy::new(|ruby| {
let parent_module = LZ4_FLEX.get_inner_with(ruby);
let standard_error = ruby.exception_standard_error();
match parent_module.define_error("Error", standard_error) {
Ok(e) => e,
Err(err) => panic!("Failed to define Error class: {}", err),
}
});
// The `unsafe` block is necessary because `get_unchecked` is the only way
// to get a `&Ruby` handle in this context. It is safe because we know
// the Ruby VM is running when this code is called.
unsafe { BASE_ERROR_CLASS.get_inner_with(&Ruby::get_unchecked()) }
}

// A placeholder function for our example.
fn compress(input: String) -> Result<String, Error> {
// In a real-world scenario, you would have your compression logic here.
Ok(format!("compressed:{}", input))
}

// Another placeholder function.
fn decompress(input: String) -> Result<String, Error> {
// In a real-world scenario, you would have your decompression logic here.
if let Some(original) = input.strip_prefix("compressed:") {
Ok(original.to_string())
} else {
Err(Error::new(base_error_class(), "invalid input".to_string()))
}
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let module = LZ4_FLEX.get_inner_with(ruby);
// This call ensures the error class is defined and available to Ruby
// when the module is loaded.
base_error_class();

module.define_singleton_method("compress", function!(compress, 1))?;
module.define_singleton_method("decompress", function!(decompress, 1))?;
Ok(())
}

Creating Classes from Rust Structs

Key Points

  • Use #[magnus::wrap] to expose a Rust struct as a Ruby class.
  • The wrapped struct holds the instance data for each Ruby object.
  • Use RefCell for interior mutability when instance data needs to be modified through an immutable (&self) reference.

Example: A Wrapped Struct with Mutable State

This example defines a Tokenizer class in Rust that can be instantiated and modified from Ruby. It's inspired by real-world tokenizers but simplified for clarity.

It uses #[magnus::wrap] to expose the Rust Tokenizer struct as a Ruby class. The struct holds the instance data for each Ruby object, and we use RefCell for interior mutability, allowing us to modify data from &self methods (which is how Magnus exposes Ruby instance methods).

use magnus::{class, function, method, prelude::*, Error, Ruby};
use std::cell::RefCell;
use std::collections::HashMap;

// This struct is wrapped and exposed to Ruby as the `Tokenizer` class.
#[magnus::wrap(class = "Tokenizer")]
struct Tokenizer {
// In a real-world scenario, this would be a more complex struct.
// Here, we use a `RefCell` around a `HashMap` to represent a vocabulary
// that can be modified.
vocab: RefCell<HashMap<String, i64>>,
next_id: RefCell<i64>,
}

impl Tokenizer {
// The constructor, exposed to Ruby as `Tokenizer.new`.
fn new() -> Self {
Self {
vocab: RefCell::new(HashMap::new()),
next_id: RefCell::new(0),
}
}

// An instance method, exposed as `tokenizer.add_token("hello")`.
// It modifies the internal state.
fn add_token(&self, token: String) -> i64 {
let mut vocab = self.vocab.borrow_mut();
let mut next_id = self.next_id.borrow_mut();

// Return existing token ID if it's already in the vocab.
if let Some(id) = vocab.get(&token) {
return *id;
}

let id = *next_id;
vocab.insert(token, id);
*next_id += 1;
id
}

// An accessor method to get the vocabulary size.
fn vocab_size(&self) -> usize {
self.vocab.borrow().len()
}
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let class = ruby.define_class("Tokenizer", ruby.class_object())?;
class.define_singleton_method("new", function!(Tokenizer::new, 0))?;
class.define_method("add_token", method!(Tokenizer::add_token, 1))?;
class.define_method("vocab_size", method!(Tokenizer::vocab_size, 0))?;
Ok(())
}

Class Inheritance and Mixins

Key Points

  • You can subclass existing Ruby classes (e.g., Array, String).
  • Define a module in Rust and include it into a class as a mixin.

Example: Subclassing and Including a Module

This example creates a custom SortedArray that subclasses Ruby's Array.

use magnus::{method, prelude::*, Error, Ruby, RArray, Value};

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
// 1. Get the parent class (Array).
let array_class = ruby.class_array();

// 2. Define a subclass of Array.
let sorted_array = ruby.define_class("SortedArray", array_class)?;

// 3. Override a method.
sorted_array.define_method("<<", method!(|rb_self: RArray, item: Value| {
rb_self.push(item)?;
rb_self.funcall::<_, _, Value>("sort!", ())?; // Call sort! on self
Ok::<magnus::RArray, magnus::Error>(rb_self)
}, 1))?;

Ok(())
}

Singleton Methods

Key Points

  • "Class methods" in Ruby are singleton methods on the class object.
  • Use define_singleton_method to define class methods.
  • Singleton methods can also be defined on any specific object instance.

Example: Class and Instance Methods

This example defines a Logger class with both instance methods (log) and class methods (default_level).

use magnus::{function, method, prelude::*, Error, Ruby};

#[magnus::wrap(class = "Logger")]
struct Logger {
level: String,
}

impl Logger {
fn new(level: String) -> Self { Self { level } }
fn log(&self, message: String) { println!("[{}] {}", self.level, message); }
fn default_level() -> &'static str { "INFO" }
}

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let class = ruby.define_class("Logger", ruby.class_object())?;
class.define_singleton_method("new", function!(Logger::new, 1))?; // Constructor
class.define_singleton_method("default_level", function!(Logger::default_level, 0))?; // Class method
class.define_method("log", method!(Logger::log, 1))?; // Instance method
Ok(())
}