PONYλM2Modula-2

C++.CodeCompared.To/Ruby

An interactive executable cheatsheet comparing C++ and Ruby

C++23 (GCC) Ruby 4.0
Where C++ Meets Ruby
Hello, World
One line, no entry point, no build — and the parentheses are optional, which is a decision that echoes through the whole language.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
puts "Hello, World!"
puts is a method call with the parentheses omitted, which Ruby allows almost everywhere and which is why Ruby code so often reads like a sentence. There is no main: a script executes top to bottom. Note also that puts is not an operator or a keyword but an ordinary private method on Object, so it is available everywhere for the same reason every other method is — which the object-model section makes precise.
Why a C++ programmer ends up here
Worth being blunt about, because "C++ programmer learns Ruby" is a rare career move and "C++ programmer writes the fast part of a Ruby program" is an extremely common one.
// The honest list, in order of how often it happens: // // 1. A native extension. Every gem that is fast is C or C++: // nokogiri (libxml2), pg, sqlite3, oj, grpc, ruby-vips. // Ruby's C API is one of the most-used C APIs in existence. // 2. mruby embedded in your program, in the same slot as Lua. // 3. Build and release tooling — Rake, Fastlane, CocoaPods, // Homebrew formulas — which is Ruby whether you chose it or not. // 4. A service around the C++ core, where iteration speed matters // more than microseconds. #include <iostream> int main() { std::cout << "the fast half of somebody's gem" << std::endl; return 0; }
# From Ruby's side the C++ is invisible, and that is the point: a gem # with a C extension is required exactly like a pure-Ruby one. # # require "nokogiri" # libxml2, in C # require "sqlite3" # SQLite, in C # require "oj" # a JSON parser in C, ~5x faster than the # # pure-Ruby one it replaces # # Ruby's design assumes this. The language is deliberately slow and # deliberately easy to extend, and the ecosystem is built on the # assumption that the hot 5% will be written in C or C++. puts "the pleasant half of somebody's gem"
Ruby made an unusual trade: the interpreter is slow by the standards of its peers, and the C API is unusually good — small, stable, and documented, with Init_ conventions that have barely changed in twenty years. The result is an ecosystem that assumes the expensive parts live in C. For a C++ programmer that is the opening: you are not being asked to write a Rails app, you are being asked to make something fast, and the next two sections are the two ways to do it.
Native Extensions
A native extension, end to end
This is the whole mechanism, and it is smaller than most C++ programmers expect — four lines of registration and a two-line build file.
// fast_math.cpp — a complete Ruby extension. Note extern "C" on // Init_: Ruby looks the symbol up by name, so it must be unmangled. // // #include <ruby.h> // // static VALUE add(VALUE self, VALUE first, VALUE second) { // long result = NUM2LONG(first) + NUM2LONG(second); // return LONG2NUM(result); // } // // extern "C" void Init_fast_math() { // VALUE module = rb_define_module("FastMath"); // rb_define_module_function(module, "add", RUBY_METHOD_FUNC(add), 2); // } // // extconf.rb, which generates the Makefile: // // require "mkmf" // create_makefile("fast_math") // // Then: ruby extconf.rb && make → fast_math.bundle #include <iostream> int main() { std::cout << "one file, one Init_, one extconf.rb" << std::endl; return 0; }
# And from Ruby it is just a require and a method call: # # require "fast_math" # FastMath.add(2, 3) # => 5 # # There is no marker, no import ceremony and no way to tell from the # call site that this is C++. A gem ships the source and compiles it # on install, which is why `gem install nokogiri` takes a minute. module FastMath def self.add(first, second) first + second end end puts FastMath.add(2, 3)
Three things to internalize. VALUE is Ruby's universal value type, the equivalent of Godot's Variant and Lua's stack slot — every Ruby object is one, and the NUM2LONG/LONG2NUM family converts. Init_<name> is found by symbol name when the library loads, so it must be extern "C" or C++ name mangling hides it. And the arity argument (2) is checked by Ruby before your function is called, so you do not validate argument counts yourself. mkmf generates a Makefile that already knows Ruby's include paths and flags.
The GVL, and how to release it
If you are writing an extension to make something fast, this is the row that decides whether it actually is.
// Ruby has a Global VM Lock: one thread executes Ruby at a time. // Your C++ extension can RELEASE it for the duration of a long // computation, which is what makes gems like nokogiri parallel: // // #include <ruby.h> // #include <ruby/thread.h> // // static void* heavy_work(void* argument) { // // No Ruby API calls in here — the GVL is NOT held. // long* total = static_cast<long*>(argument); // for (long index = 0; index < 100000000; ++index) { *total += index; } // return nullptr; // } // // static VALUE compute(VALUE self) { // long total = 0; // rb_thread_call_without_gvl(heavy_work, &total, RUBY_UBF_IO, nullptr); // return LONG2NUM(total); // } // // 🚨 Touching ANY Ruby API without the GVL is a crash or corruption. #include <iostream> int main() { std::cout << "release the lock, but touch nothing Ruby owns" << std::endl; return 0; }
# From Ruby, threads exist and are real OS threads — but the GVL # means only one runs Ruby bytecode at a time. # # Concurrency: yes. Blocking I/O releases the GVL, so threaded # network code genuinely overlaps. # Parallelism: no, for pure Ruby. Four CPU-bound threads take as # long as one. # # The escapes are the same as Python's: separate processes, or C # extensions that release the lock (above), or Ractors — Ruby's # actor model, which gives real parallelism and is still experimental. results = [1, 2, 3, 4].map { |value| value * value } puts results.inspect puts "threads: concurrency yes, CPU parallelism no"
rb_thread_call_without_gvl is what turns a C++ extension from "faster interpreter" into "actually parallel": while it runs, other Ruby threads execute. The absolute rule is that the callback must not touch any Ruby API — no VALUEs, no allocation, no exceptions — because the interpreter's invariants are not held. Convert your inputs to plain C++ types before releasing, work, then convert back after reacquiring. This is the same shape as the GIL discussion on /cpp/python, and the same escape hatch.
mruby: Ruby You Embed
mruby is Ruby in the Lua slot
If you liked the Lua page's bargain but would rather write Ruby, this is the same bargain with a different language on top.
// mruby is a separate implementation designed to be EMBEDDED — // small, ISO-conforming, no global interpreter state, MIT licensed. // The API shape will look familiar after the Lua page: // // #include <mruby.h> // #include <mruby/compile.h> // // int main() { // mrb_state* mrb = mrb_open(); // like luaL_newstate() // mrb_load_string(mrb, "puts 'hello from mruby'"); // mrb_close(mrb); // like lua_close() // return 0; // } // // Differences from CRuby that matter for embedding: a few hundred KB // instead of several MB, no GVL, multiple independent interpreters in // one process, and a build you configure feature by feature. #include <iostream> int main() { std::cout << "mrb_open, mrb_load_string, mrb_close" << std::endl; return 0; }
# The script side is ordinary Ruby, minus the parts a small embedded # build leaves out: # # Present: classes, modules, blocks, Enumerable, exceptions, # String, Array, Hash, Struct, most of the core # Absent: the standard library beyond the core, refinements, # some of Ruby's more exotic reflection, and whatever # your build's mrbgems did not include # # It is used where Lua usually is: game logic, device firmware, # ITAMAE-style config, and inside a few C++ applications that wanted # a nicer language than Lua for the same job. puts "ordinary Ruby, on a smaller runtime"
mruby exists because CRuby is a poor fit for embedding: it assumes one interpreter per process, carries a large runtime, and its GVL is process-global. mruby fixes all three — mrb_state is fully self-contained, so a program can run several independently, and the build is assembled from "mrbgems" so you include only what you need. The trade against Lua is size and maturity of the embedding ecosystem: Lua is smaller and has far more prior art, while mruby gives you blocks, real classes and Enumerable. Where CRuby is what you extend, mruby is what you embed.
Everything Is an Object
There are no primitives
C++ has objects and also things that are not objects — int, double, functions, types. Ruby's second category is empty.
#include <iostream> #include <typeinfo> int main() { int value = 42; // An int has no members, no methods, and no runtime type you can // ask about without RTTI on a polymorphic type. std::cout << value << " " << sizeof(value) << " bytes" << std::endl; // -5's absolute value is a free function, not a method. std::cout << std::abs(-5) << std::endl; return 0; }
value = 42 # 42 is an object. It has a class, methods, and ancestors. puts value.class puts value.abs puts(-5.abs) puts 42.even? puts 3.times.to_a.inspect # So does nil, and so does the class itself. puts nil.class puts Integer.class puts 42.is_a?(Comparable)
Every value is an object with a class, so 42.abs, 42.even? and nil.class all work, and Integer is itself an instance of Class. That uniformity is what makes the rest of the language possible: if integers are objects then they can be stored in generic containers without boxing rules, respond to <=> through Comparable, and be reopened like any other class. The cost is the obvious one — an Integer is a heap object unless the interpreter can tag it inline (small integers are, as "fixnums"), and every operation is a method dispatch.
Every variable is a reference
Same rule as Python, Kotlin and GDScript: assignment binds a name and never copies.
#include <iostream> #include <vector> int main() { std::vector<int> first{1, 2, 3}; std::vector<int> second = first; // a COPY second.push_back(4); std::cout << first.size() << " " << second.size() << std::endl; return 0; }
first = [1, 2, 3] second = first # NOT a copy: two names for one array second << 4 puts "#{first.size} #{second.size}" # Copying is explicit, and shallow by default. third = first.dup third << 5 puts "#{first.size} #{third.size}"
The C++ column prints 3 4 and the Ruby column prints 4 4, because second = first aliases. There are no value types for your own classes and no way to ask for one — dup and clone are explicit and shallow, so an array of arrays duplicated with dup still shares its inner arrays. Integers, symbols, nil, true and false appear to have value semantics only because they are immutable. This is the single fact that explains most surprises a C++ programmer will hit.
Blocks
The block: a lambda with privileged syntax
This is the construct C++ has no word for, and the one that shapes how every Ruby API is designed.
#include <algorithm> #include <iostream> #include <vector> int main() { std::vector<int> readings{5, 3, 9, 1}; // A lambda is an argument like any other, with parentheses and // a capture list. auto above = std::count_if(readings.begin(), readings.end(), [](int value) { return value > 4; }); std::cout << above << std::endl; return 0; }
readings = [5, 3, 9, 1] # A block is not an argument in the parameter list — it is attached # to the call, in braces or do/end, and only ONE may be passed. above = readings.count { |value| value > 4 } puts above readings.each do |value| print value, " " end puts # Which is why Ruby has no `for` loop anyone uses: iteration is a # method that takes a block. 3.times { |index| print index, " " } puts
A block is a chunk of code attached to a call site with its own syntax — { } for one line, do/end for several — and exactly one may be passed, which is why it needs no name. That privileged position is why Ruby's iteration, resource management, DSLs and callbacks all look the same: they are methods taking blocks. For a C++ programmer the closest analogy is a lambda that the language decided to make so cheap to write that it became the default way to structure control flow. yield, in the next row, is how a method calls it.
yield, and blocks as resource management
Ruby has no destructors, and blocks are what it uses instead — the same job RAII does, arranged the other way round.
#include <iostream> #include <string> // RAII: cleanup belongs to the type, and runs at scope exit. class Transaction { public: explicit Transaction(std::string name) : name_(std::move(name)) { std::cout << "begin " << name_ << std::endl; } ~Transaction() { std::cout << "end " << name_ << std::endl; } private: std::string name_; }; int main() { { Transaction transaction("payment"); std::cout << "working" << std::endl; } return 0; }
# Ruby's answer is a method that brackets the block it is given. def transaction(name) puts "begin #{name}" yield # runs the caller's block ensure puts "end #{name}" # runs even if the block raises end transaction("payment") do puts "working" end # Which is exactly how File.open, Mutex#synchronize and every other # resource in the standard library works: # File.open("x") { |file| ... } # closed on exit, always
The method wraps the block: setup, yield, and an ensure that runs whether the block returned or raised. That is the with statement of Python, the use of Kotlin and the defer of Go, except that it reads as an ordinary method call — so the standard library exposes every resource this way and there is no second spelling to learn. The difference from RAII is the familiar one: the guarantee lives in the API rather than in the type, so a caller who uses File.open without a block gets an unclosed file and no warning.
Enumerable: one module, fifty methods
C++ gives you free functions over iterators; Ruby gives you a mixin over one method, and the difference in reach is large.
#include <iostream> #include <numeric> #include <ranges> #include <vector> int main() { std::vector<int> readings{1, 2, 3, 4, 5, 6}; auto pipeline = readings | std::views::filter([](int value) { return value % 2 == 0; }) | std::views::transform([](int value) { return value * value; }); int total = std::accumulate(pipeline.begin(), pipeline.end(), 0); std::cout << total << std::endl; return 0; }
readings = [1, 2, 3, 4, 5, 6] total = readings.select { |value| value.even? } .map { |value| value * value } .sum puts total # Enumerable is a MODULE. Define `each` on your own class, include # it, and all fifty methods appear. puts readings.each_slice(2).to_a.inspect puts readings.partition(&:even?).inspect puts readings.each_cons(3).first.inspect puts readings.group_by { |value| value % 3 }.inspect
Implement each on a class and include Enumerable, and you get map, select, reduce, sort_by, group_by, each_slice, partition, lazy and forty more — for free, on your own type. C++20 ranges reach a similar place through concepts, with the crucial difference that a ranges pipeline is lazy and compiles away, where Ruby's select and map each allocate a new array. readings.lazy.select{…}.map{…}.first(3) is the lazy form when the collection is large. Note &:even?, which turns a symbol into a block.
Duck Typing & Open Classes
Duck typing is a template checked at call time
The framing that makes Ruby legible to a C++ programmer: duck typing is templates without the compile step, not inheritance without the types.
#include <iostream> #include <string> // A template accepts any type with the right shape, checked when // instantiated — structural, with no declaration required. template <typename Speaker> void introduce(const Speaker& speaker) { std::cout << speaker.speak() << std::endl; } struct Dog { std::string speak() const { return "woof"; } }; struct Robot { std::string speak() const { return "beep"; } }; int main() { introduce(Dog{}); introduce(Robot{}); return 0; }
# The same structural acceptance, checked when the line RUNS. def introduce(speaker) puts speaker.speak end class Dog def speak = "woof" end class Robot def speak = "beep" end introduce(Dog.new) introduce(Robot.new) # And you can ask, which a template cannot do at runtime. puts Dog.new.respond_to?(:speak)
Both accept any type structurally supporting the operations used, and neither needs a common base class. The difference is when: instantiation time for the template, call time for Ruby — so the template gives a compile error and specialized machine code, and Ruby gives a NoMethodError at the moment the bad call is reached. respond_to? is the runtime capability check, the same role has_method plays in GDScript. Note def speak = "woof": endless method definition, Ruby 3.0 and later, for one-expression methods.
Open classes: every type is extensible
This is Ruby's most powerful and most dangerous feature, and it has no C++ equivalent at any level.
#include <iostream> #include <string> // You cannot add a member function to std::string. A free function // is the answer, and it does not read as a method. bool isShouting(const std::string& text) { if (text.empty()) { return false; } for (char character : text) { if (std::islower(static_cast<unsigned char>(character))) { return false; } } return true; } int main() { std::cout << std::boolalpha << isShouting("HELLO") << std::endl; return 0; }
# Any class can be reopened, including the built-in ones — and the # method is added to every instance that already exists. class String def shouting? !empty? && self == upcase end end puts "HELLO".shouting? puts "Hello".shouting? # Which is enormous power and a genuine hazard: this is global, and # two libraries redefining the same method is the "monkey patching" # problem. Refinements (`refine`) scope it to one file.
Reopening a class modifies it globally and retroactively, so "HELLO" created before the reopening still gains the method. That is what makes Rails' 2.days.ago possible and what makes a gem that patches String#to_json break another gem that patched it differently. Compare Swift and Rust, whose extensions are additive-only and bounded by an orphan rule, and Kotlin's, which are static functions in disguise — Ruby's is the unrestricted version. refine/using scopes a patch lexically and is the disciplined form, though it sees relatively little use.
Collections & Enumerable
Array and Hash
The containers map across; two of the conveniences do not, and one of them changes how Hash keys are written.
#include <iostream> #include <map> #include <string> #include <vector> int main() { std::vector<int> readings{12, 7, 30}; readings.push_back(4); std::map<std::string, int> stock{{"widget", 7}}; std::cout << readings.size() << " " << readings[0] << " " << stock["widget"] << std::endl; return 0; }
readings = [12, 7, 30] readings << 4 # << is push_back stock = { "widget" => 7, gadget: 3 } # string key, then symbol key puts "#{readings.size} #{readings[0]} #{stock["widget"]}" # Negative indexing, ranges, and a default for missing keys. puts readings[-1] puts readings[1..2].inspect puts stock.fetch("sprocket", 0)
Array is a growable vector of object references (so no contiguous ints and no cache locality — the same cost as Python's list) and Hash is a hash map that preserves insertion order, guaranteed. Two things with no C++ counterpart: negative indexing (readings[-1] is the last element) and slicing with ranges. And note gadget: 3 — that is a symbol key, the idiomatic choice for anything fixed, which the strings section explains.
Strings & Symbols
Strings are mutable, and interpolate
Ruby strings are mutable — unlike Python, Java, Go and Swift — which is a real difference and the reason for the + above.
#include <format> #include <iostream> #include <string> int main() { std::string greeting = "hello"; greeting += ", world"; std::string product = "widget"; int quantity = 7; std::cout << std::format("{} x{}", product, quantity) << std::endl; std::cout << greeting << " " << greeting.size() << std::endl; return 0; }
greeting = +"hello" # unary + gives a mutable copy greeting << ", world" # << appends IN PLACE product = "widget" quantity = 7 puts "#{product} x#{quantity}" # interpolation, not formatting puts "#{greeting} #{greeting.size}" puts greeting.frozen?
<< appends in place rather than building a new string, so string building in a loop is O(n) here where it is O(n²) in Python. The + prefix asks for a mutable copy, which matters because Ruby has been moving toward frozen string literals for years: a literal is currently "chilled" — writable but warning — and frozen_string_literal: true at the top of a file makes literals genuinely immutable, which most modern codebases set. Interpolation with #{} runs arbitrary expressions and calls to_s, which is why Ruby rarely reaches for a format string.
Symbols are interned identifiers
Symbols have no C++ equivalent and appear on nearly every line of real Ruby, so they are worth understanding early.
#include <iostream> #include <string> int main() { // Two equal strings are two objects; comparison is a memcmp. std::string first = "status"; std::string second = "stat"; second += "us"; std::cout << std::boolalpha << (first == second) << " " << (&first == &second) << std::endl; return 0; }
# A Symbol is an interned, immutable name. Two occurrences of :status # are THE SAME OBJECT, so comparison is a pointer compare. first = :status second = :status puts "#{first == second} #{first.equal?(second)}" puts first.object_id == second.object_id # Two equal Strings are different objects. puts "status".equal?("status") # Which is why hash keys, method names and options are symbols. puts({ status: :active }.inspect)
A Symbol is an interned name: every occurrence of :status in a program is the same object, so equality is a pointer comparison and hashing is trivial. That is why they are the idiomatic choice for hash keys, method names, option flags and anything else drawn from a fixed vocabulary — the closest C++ analogue is an interned string or an enum used as a key. The rule of thumb: a symbol is a name, a string is data. Symbols were never garbage collected before Ruby 2.2, which is why old advice warns against generating them dynamically.
Classes, Modules & Mixins
Classes, and attr_accessor
The shape is familiar; three details differ, and the third is a philosophical difference rather than a missing feature.
#include <iostream> #include <string> class Counter { public: Counter() : total_(0) {} void increment(int by) { total_ += by; } int total() const { return total_; } private: int total_; }; int main() { Counter counter; counter.increment(5); std::cout << counter.total() << std::endl; return 0; }
class Counter attr_reader :total # generates the getter def initialize @total = 0 # @ marks an instance variable end def increment(by) @total += by end end counter = Counter.new counter.increment(5) puts counter.total # private exists, and is advisory in the way `_name` is in Python: # send() reaches straight past it.
Instance variables are marked with @ and are always private — there is no way to read one from outside, which is why attr_reader, attr_writer and attr_accessor exist to generate the accessors. initialize is the constructor, called by new. And private applies to methods only, and is advisory: counter.send(:some_private_method) works, deliberately, on the same "we are all adults" reasoning Python uses. For a C++ programmer that is a real loss of enforcement in exchange for testability and introspection.
Modules are multiple inheritance done safely
Ruby has single inheritance plus mixins, which gets most of what multiple inheritance is for without the part that made it hard.
#include <iostream> #include <string> // Multiple inheritance is allowed, which is why virtual bases and // the diamond problem exist. class Printable { public: virtual ~Printable() = default; virtual std::string describe() const = 0; void print() const { std::cout << describe() << std::endl; } }; class Report : public Printable { public: std::string describe() const override { return "a report"; } }; int main() { Report report; report.print(); return 0; }
# A module is a bag of methods with no instances of its own. module Printable def print_it puts describe # calls the INCLUDER's method end end class Report include Printable # mixed in def describe = "a report" end Report.new.print_it # The ancestor chain is LINEARIZED, so there is no diamond problem. puts Report.ancestors.first(3).inspect
A module cannot be instantiated and holds no state of its own, so includeing several of them cannot produce two copies of a base — Ruby linearizes the ancestor chain instead, and ancestors shows you the resulting order. That resolves the diamond problem by construction rather than by virtual inheritance. Modules also serve as namespaces (Math::PI), which is the other half of what C++ uses namespace for. extend mixes into a single object rather than a class, and prepend inserts before the class, which is how method wrapping is done.
Error Handling
Exceptions, with retry and ensure
The mechanism is the one you know; Ruby adds two clauses and one genuinely unusual keyword.
#include <iostream> #include <stdexcept> #include <string> int parsePort(const std::string& text) { int value = std::stoi(text); if (value < 0) { throw std::out_of_range("negative"); } return value; } int main() { try { std::cout << parsePort("8080") << std::endl; std::cout << parsePort("-1") << std::endl; } catch (const std::exception& problem) { std::cout << "failed: " << problem.what() << std::endl; } // No finally: cleanup rides on destructors. return 0; }
def parse_port(text) value = Integer(text) raise ArgumentError, "negative" if value.negative? value end begin puts parse_port("8080") puts parse_port("-1") rescue ArgumentError => problem puts "failed: #{problem.message}" else puts "only if nothing raised" ensure puts "always" end
Everything raisable descends from Exception, and the conventional root for application errors is StandardError — a bare rescue catches that rather than everything, which is what you want. else runs when nothing raised, ensure always, and both have C++ counterparts only in the sense that destructors cover the second. The unusual one is retry: inside a rescue, it re-runs the begin block from the top, which makes "try three times with backoff" a five-line construct with no loop. Note Integer(text) rather than text.to_i, which returns 0 for garbage.
Memory & Cleanup
No destructors, and no reliable finalizer
Ruby has a mark-and-sweep collector and no deterministic destruction at all — the strongest form of the trade every managed language on this anchor makes.
#include <iostream> #include <string> class Logger { public: explicit Logger(std::string name) : name_(std::move(name)) { std::cout << "open " << name_ << std::endl; } ~Logger() { std::cout << "close " << name_ << std::endl; } private: std::string name_; }; int main() { { Logger logger("audit"); } // GUARANTEED here std::cout << "after scope" << std::endl; return 0; }
class Logger def initialize(name) @name = name puts "open #{name}" end def close puts "close #{@name}" end # There is no destructor. ObjectSpace.define_finalizer exists, # runs at an unpredictable time, and must not close over the # object itself — so nobody uses it for cleanup. end logger = Logger.new("audit") logger.close # explicit, or via a block method puts "after scope"
There is no destructor, and ObjectSpace.define_finalizer is a trap: it runs at an unpredictable time, may not run before exit, and capturing the object in the finalizer block keeps it alive forever. So cleanup is either an explicit method or — idiomatically — a block-taking method that ensures it, which is the row on yield above. The practical rule for a C++ programmer: any resource with a lifetime should be handed out through a block-taking API, so callers cannot forget. Ruby's collector is generational and incremental, and unlike Python's it is not reference counted, so cycles are collected but nothing is prompt.
Metaprogramming
Defining methods at runtime
This is where Ruby is doing something C++ genuinely cannot, and it explains a great deal of what Rails looks like.
#include <iostream> #include <string> // C++ generates code at COMPILE time: templates, macros, and (from // C++26) reflection. Nothing can add a member function at runtime. #define DEFINE_GETTER(name, value) \ std::string name() const { return value; } class Config { public: DEFINE_GETTER(host, "localhost") DEFINE_GETTER(port, "8080") }; int main() { Config config; std::cout << config.host() << ":" << config.port() << std::endl; return 0; }
class Config # define_method builds methods at RUNTIME, from data. { host: "localhost", port: "8080" }.each do |name, value| define_method(name) { value } end end config = Config.new puts "#{config.host}:#{config.port}" # And method_missing catches calls that do not exist, which is how # ActiveRecord's find_by_name_and_email works. puts config.respond_to?(:host) puts Config.instance_methods(false).sort.inspect
define_method creates methods from data at runtime, and method_missing intercepts calls to methods that do not exist — together they are how ActiveRecord synthesizes find_by_name_and_email from a schema it read at boot. C++'s metaprogramming is strictly compile-time (templates, macros, and P2996 reflection in C++26), which is faster and checkable and cannot respond to a database. The cost is the one you would expect: no tooling can be sure what methods a class has, so autocomplete, static analysis and grep all get less reliable the more of this a codebase does.
Performance
What a loop costs, and what to do about it
The number is large and mostly irrelevant, for the same reason it is on the Python page — but the mitigations are different enough to be worth knowing.
#include <iostream> int main() { // A few machine instructions on a register per iteration. long long total = 0; for (int index = 0; index < 1000000; index += 1) { total += index; } std::cout << total << std::endl; return 0; }
# Every iteration is a method dispatch on a heap object, through the # YARV bytecode interpreter. Roughly 50-100x the C++ loop. total = 0 1_000_000.times { |index| total += index } puts total # The idiomatic fix is the same as Python's: push the loop into C. puts (0...1_000_000).sum # And since Ruby 3.1 there is a JIT (YJIT) that closes a good part of # the gap on real workloads without changing any code.
Ruby is slow for the usual dynamic-language reasons: every value is an object, every operation is a dispatch, and open classes mean almost nothing can be resolved ahead of time. Three things change the picture. Range#sum and friends run the loop in C, exactly as sum(range(...)) does in Python. YJIT, shipped in Ruby 3.1 and mature since 3.3, gives real speedups on Rails-shaped workloads with no code changes and is enabled with --yjit. And a native extension moves the hot part to C++ entirely, which is where this page started.
Ruby as Glue
The Ruby you will meet whether you chose it or not
This is the reason to read the blocks section even if you never write a native extension: it is what makes every one of these files look the way it does.
// A C++ project's build and release tooling is usually several // languages: CMake's DSL, shell, Python, YAML, and Make. Each with // its own quoting rules and its own failure modes. // // cmake -B build && cmake --build build // ./scripts/package.sh // python3 tools/upload.py #include <iostream> int main() { std::cout << "four languages before the first test runs" << std::endl; return 0; }
# A Rakefile is Ruby, and the DSL is just methods taking blocks — # which is the payoff of the blocks section. # # task :build do # sh "cmake --build build" # end # # task default: [:build, :test] # # You will meet Ruby here whether or not you chose it: Homebrew # formulas are Ruby, CocoaPods podspecs are Ruby, Fastlane is Ruby, # Vagrantfiles are Ruby, Chef recipes are Ruby. task_list = { build: "cmake --build build", test: "ctest" } task_list.each { |name, command| puts "#{name}: #{command}" }
Ruby's blocks plus optional parentheses plus method_missing make internal DSLs unusually pleasant to write, which is why so much infrastructure tooling landed here — a Rakefile, a Homebrew formula and a Podspec are all just Ruby with methods taking blocks, not bespoke config formats. For a C++ programmer that means the syntax is worth twenty minutes even if the language never enters your build: you will be editing these files, and knowing that task :build do … end is a method call with a symbol and a block turns a mysterious format into something you can reason about.
Where the line goes
Worth ending on the same question the Lua and GDScript pages end on, because Ruby answers it the same way and for a reason worth naming.
// What stays in C++, and why: // // The hot 5% — parsing, encoding, numerics, image and video work // Anything wrapping an existing C or C++ library // Anything that must release the GVL to be parallel // Anything with a lifetime that must be deterministic // // The C API is small, stable and well documented, and the cost of // crossing it is a VALUE conversion per argument — so the usual rule // applies: cross rarely, with a lot of work. #include <iostream> int main() { std::cout << "the fast part, and the lifetimes" << std::endl; return 0; }
# What Ruby is for: # # Anything that changes often, or is read more than it is run # Orchestration, tooling, build and release # The service around the C++ core # DSLs, because blocks make them cheap # # The honest summary: Ruby is the only target on this anchor whose # ECOSYSTEM assumes your C++ exists. Nokogiri is libxml2, pg is # libpq, sqlite3 is SQLite — the gems that matter are wrappers, and # writing one is an ordinary C++ job with an unusually small API. puts "the pleasant part, and the iteration speed"
Like Lua and GDScript, Ruby is not competing with C++ for the same work — but unlike them it is not embedded in your program either. The arrangement is the reverse: your C++ goes inside its process, as an extension. That inversion is why the C API is so good and so stable, and why the ecosystem is full of thin Ruby over serious C. If you are a C++ programmer with a library worth using, writing the gem that wraps it is a well-trodden path — four lines of registration, an extconf.rb, and a VALUE conversion at each boundary.