Tracking subclasses with Class#subclasses
The Class#subclasses method allows us to retrieve the direct subclasses of a class at runtime:
class Parent
end
class Child < Parent
end
AnotherChild = Class.new(Parent)
Parent.subclasses.sort_by(&:name)
# => [AnotherChild, Child]
# Only direct subclasses are tracked, so it won't include GrandChild below:
class GrandChild < Child
end
Parent.subclasses.sort_by(&:name)
# => [AnotherChild, Child]
Continuing our ongoing CLI example, we can change the approach to register commands by inheriting from a base Command class and then use subclasses for command discovery:
class Command
end
class Greet < Command
def description = "Greet someone"
def usage = "cli.rb greet <name>"
def call(name) = puts "Hello, #{name}!"
end
class Add < Command
def description = "Add two numbers"
def usage = "cli.rb add <x> <y>"
def call(x, y) = puts x.to_i + y.to_i
end
help = ARGV.delete("--help")
if ARGV.empty?
puts "Usage: cli.rb <command> [args...]"
puts
puts "Available commands:"
Command.subclasses.sort_by(&:name).each do |subclass|
puts " #{subclass.name.downcase}"
end
exit
end
command_name = ARGV.shift
command_class = Command.subclasses.find { |c| c.name.downcase == command_name }
abort "ERROR: Unknown command - #{command_name}" unless command_class
command = command_class.new
if help
puts "Command: #{command_name}"
puts command.description
puts "Usage: #{command.usage}"
exit
end
command.call(*ARGV)
Adding commands is now as simple as defining a new class that inherits from Command.
As a bonus, we're now rendering a list of available commands when the user doesn't provide any arguments:
$ ruby cli.rb
Usage: cli.rb <command> [args...]
Available commands:
add
greet
Admittedly, the value of using subclasses here is limited. Many Rubyists would likely prefer using modules, and I would not necessarily disagree. But hey, without inheriting we can't showcase the subclasses method, can we? 😇
History
Class#subclasses was added in Ruby 3.1, released on Christmas 2021. The method exposes part of the subclass tracking Ruby already maintained internally for the class hierarchy and method-cache invalidation.
Rails developers may recognize the idea from ActiveSupport's descendants tracker. Ruby's built-in version is narrower since it only returns direct child classes, while Rails often wants recursive descendants for framework tasks such as finding models and controllers.
Before Class#subclasses was introduced, subclasses could be tracked through the inherited hook, which we may look at some other day!