CrackedRuby logo

CrackedRuby

Numeric Coercion

Overview

Numeric coercion in Ruby provides a standardized mechanism for performing arithmetic operations between different numeric types. When Ruby encounters an operation like 3 + 4.5, it uses the coercion protocol to convert both operands to compatible types before executing the operation.

The coercion system centers around the coerce method, which each numeric class implements to handle type conversion. When a binary operation involves two different numeric types, Ruby calls coerce on the right operand, passing the left operand as an argument. The method returns an array containing both operands converted to compatible types.

# Integer and Float operation
result = 3 + 4.5
# Ruby internally calls: 4.5.coerce(3)
# Returns: [3.0, 4.5] (both converted to Float)
# Then performs: 3.0 + 4.5 = 7.5

# Rational and Integer operation
rational = Rational(1, 2)
result = rational + 3
# Ruby calls: 3.coerce(rational) 
# Returns: [Rational(1, 2), Rational(3, 1)]
# Then performs the addition with both as Rational

Ruby's numeric hierarchy includes Integer, Float, Rational, and Complex types, each implementing coercion rules that generally promote values to the more precise or general type. Integer coerces to Float, Float coerces to Complex, and Rational coerces based on the specific operation context.

The coercion protocol handles not just basic arithmetic but also comparison operations, ensuring consistent behavior across Ruby's numeric types. This system allows Ruby to maintain mathematical accuracy while providing intuitive operation semantics.

Basic Usage

The coercion protocol operates transparently during arithmetic operations, but understanding its mechanics helps predict operation results and debug unexpected behavior. Ruby invokes coercion when the left operand's class doesn't define a method for operating with the right operand's type.

# Basic type promotion examples
puts 5 + 2.7        # => 7.7 (Integer + Float = Float)
puts 1.5 * 4        # => 6.0 (Float * Integer = Float)
puts 10 / 3.0       # => 3.333... (Integer / Float = Float)

# Rational number coercion
r = Rational(2, 3)
puts r + 1          # => 5/3 (Rational + Integer = Rational)
puts 2.5 + r        # => 3.1666... (Float + Rational = Float)

# Complex number coercion
c = Complex(3, 4)
puts c + 7          # => (10+4i) (Complex + Integer = Complex)
puts 2.1 + c        # => (5.1+4i) (Float + Complex = Complex)

Direct coercion method calls reveal the conversion process. Each numeric type implements coerce to return an array where both elements are the same type, enabling the subsequent operation.

# Explicit coercion calls
puts 4.5.coerce(3)           # => [3.0, 4.5]
puts Complex(1, 2).coerce(5) # => [(5+0i), (1+2i)]
puts Rational(1, 4).coerce(2) # => [Rational(2, 1), Rational(1, 4)]

# Coercion maintains precision when possible
puts Rational(1, 3).coerce(0.5)  # => [0.5, 0.3333...]
puts Float::INFINITY.coerce(100)  # => [100.0, Infinity]

The coercion system handles edge cases involving special float values, maintaining mathematical properties across operations.

# Special float values
puts (Float::NAN).coerce(42)        # => [42.0, NaN]
puts (Float::INFINITY).coerce(-10)  # => [-10.0, Infinity]

# Coercion with zero
puts 0.coerce(Complex(3, 4))        # => [(0+0i), (3+4i)]
puts Rational(0, 1).coerce(2.5)     # => [2.5, 0.0]

Ruby raises TypeError when coercion between incompatible types fails, though this rarely occurs within the standard numeric hierarchy.

# Error conditions
begin
  "string".coerce(5)
rescue NoMethodError => e
  puts "String doesn't implement coerce: #{e.message}"
end

class NonNumeric
  def coerce(other)
    raise TypeError, "Cannot coerce #{other.class} to NonNumeric"
  end
end

Advanced Usage

Custom numeric classes can participate in Ruby's coercion protocol by implementing the coerce method according to specific conventions. The method should return an array where the first element is the coerced version of the argument, and the second element is the coerced version of self.

class FixedPoint
  attr_reader :value, :scale
  
  def initialize(value, scale = 2)
    @value = (value * (10 ** scale)).round
    @scale = scale
  end
  
  def to_f
    @value.to_f / (10 ** @scale)
  end
  
  def coerce(other)
    case other
    when Integer, Float
      [FixedPoint.new(other, @scale), self]
    when Rational
      [FixedPoint.new(other.to_f, @scale), self]
    else
      raise TypeError, "#{other.class} can't be coerced into FixedPoint"
    end
  end
  
  def +(other)
    if other.is_a?(FixedPoint) && other.scale == @scale
      FixedPoint.new(to_f + other.to_f, @scale)
    else
      # Trigger coercion
      a, b = other.coerce(self)
      a + b
    end
  end
  
  def inspect
    "FixedPoint(#{to_f})"
  end
end

fp = FixedPoint.new(3.14)
puts fp + 2.86          # Uses coercion to handle Integer
puts fp + Rational(1, 4) # Uses coercion to handle Rational

Metaprogramming with coercion requires careful consideration of method lookup and inheritance chains. Ruby's method dispatch mechanism determines when coercion occurs based on whether the receiver's class defines the appropriate operator method.

module CoercionLogging
  def coerce(other)
    puts "Coercing #{other.class}(#{other}) with #{self.class}(#{self})"
    result = super
    puts "Result: #{result.inspect}"
    result
  end
end

class LoggingFloat < Float
  prepend CoercionLogging
end

lf = LoggingFloat.new(2.5)
result = 3 + lf  # Demonstrates coercion logging

Advanced coercion scenarios involve handling precision loss, overflow conditions, and maintaining mathematical properties across conversions.

class SafeInteger
  attr_reader :value
  
  def initialize(value)
    raise ArgumentError, "Value too large" if value.abs > 10**15
    @value = value
  end
  
  def coerce(other)
    case other
    when Integer
      if other.abs <= 10**15
        [SafeInteger.new(other), self]
      else
        # Promote to Float to handle large values
        [other.to_f, @value.to_f]
      end
    when Float
      # Always promote to Float for mixed operations
      [@value.to_f, other]
    else
      raise TypeError, "Cannot coerce #{other.class}"
    end
  end
  
  def +(other)
    if other.is_a?(SafeInteger)
      result_value = @value + other.value
      SafeInteger.new(result_value)
    else
      a, b = coerce(other)
      a + b
    end
  rescue ArgumentError
    # Handle overflow by promoting to Float
    @value.to_f + other.to_f
  end
end

Coercion behavior becomes complex when dealing with inheritance hierarchies and mixed numeric operations that involve multiple coercion steps.

# Complex inheritance and coercion interactions
class ExtendedRational < Rational
  def coerce(other)
    case other
    when ExtendedRational
      [other, self]
    when Numeric
      # Delegate to parent class for standard coercion
      coerced = super
      [coerced[0], self]  # Keep self as ExtendedRational
    else
      raise TypeError, "Cannot coerce #{other.class}"
    end
  end
  
  def self.from_rational(rational)
    new(rational.numerator, rational.denominator)
  end
end

# Chained operations with multiple coercions
er = ExtendedRational.new(3, 4)
result = er + 1.5 + Complex(2, 1)  # Multiple coercion steps

Performance & Memory

Numeric coercion introduces performance overhead through method dispatch, object allocation, and type conversion. The cost varies significantly based on the types involved and the frequency of operations.

Benchmarking reveals that coercion penalties are most noticeable in tight loops with mixed numeric types. Integer-to-Float coercion is relatively fast, while Rational and Complex conversions involve more computation.

require 'benchmark'

# Performance comparison of same-type vs mixed-type operations
n = 1_000_000

Benchmark.bm(20) do |x|
  x.report("Integer + Integer:") do
    n.times { 100 + 200 }
  end
  
  x.report("Float + Float:") do
    n.times { 100.0 + 200.0 }
  end
  
  x.report("Integer + Float:") do
    n.times { 100 + 200.0 }  # Triggers coercion
  end
  
  x.report("Rational operations:") do
    r1, r2 = Rational(1, 3), Rational(2, 5)
    n.times { r1 + r2 }
  end
  
  x.report("Mixed Rational:") do
    r = Rational(1, 3)
    n.times { r + 2 }  # Triggers coercion
  end
end

Memory allocation patterns show that coercion creates temporary objects during conversion. Rational numbers allocate multiple objects (numerator, denominator), while Float coercion typically requires single object allocation.

# Memory allocation analysis
def analyze_allocations(&block)
  before = GC.stat[:total_allocated_objects]
  block.call
  after = GC.stat[:total_allocated_objects]
  after - before
end

# Compare allocation patterns
puts "Integer operations: #{analyze_allocations { 1000.times { 5 + 3 } }} objects"
puts "Mixed Int/Float: #{analyze_allocations { 1000.times { 5 + 3.0 } }} objects"
puts "Rational creation: #{analyze_allocations { 1000.times { Rational(1,2) + 3 } }} objects"
puts "Complex coercion: #{analyze_allocations { 1000.times { Complex(1,2) + 5.0 } }} objects"

Optimization strategies for coercion-heavy code include type consistency, caching converted values, and using specialized numeric classes that minimize coercion overhead.

# Optimization: Maintain type consistency
class OptimizedCalculation
  def self.compute_average_slow(numbers)
    # Triggers repeated coercion if mixed types
    numbers.sum / numbers.length
  end
  
  def self.compute_average_fast(numbers)
    # Pre-convert to consistent type
    floats = numbers.map(&:to_f)
    floats.sum / floats.length
  end
end

# Optimization: Cache coerced values
class CoercionCache
  def initialize
    @cache = {}
  end
  
  def coerced_add(a, b)
    key = [a.class, a, b.class, b]
    @cache[key] ||= begin
      if a.class == b.class
        a + b
      else
        coerced_a, coerced_b = a.coerce(b)
        coerced_a + coerced_b
      end
    end
  end
end

Large-scale numeric computations benefit from avoiding unnecessary precision. Using Float operations instead of Rational arithmetic can provide significant performance improvements when exact precision isn't required.

# Precision vs Performance trade-offs
class PerformanceComparison
  def self.precise_calculation(iterations)
    result = Rational(0)
    iterations.times do |i|
      result += Rational(1, i + 1)  # Harmonic series with exact precision
    end
    result
  end
  
  def self.fast_calculation(iterations)
    result = 0.0
    iterations.times do |i|
      result += 1.0 / (i + 1)  # Float arithmetic, some precision loss
    end
    result
  end
end

# Benchmark shows Float version is significantly faster
# while maintaining reasonable precision for most applications

Common Pitfalls

Precision loss during coercion operations catches many developers by surprise, particularly when Rational numbers convert to Float during mixed operations. The conversion from exact rational arithmetic to floating-point approximation can accumulate errors in iterative calculations.

# Precision loss example
rational_third = Rational(1, 3)
puts rational_third           # => (1/3) - exact representation
puts rational_third + 0.1     # => 0.43333... - lost exactness due to Float coercion

# Accumulation of precision errors
sum_rational = Rational(0)
sum_float = 0.0

1000.times do |i|
  sum_rational += Rational(1, 7)
  sum_float += Rational(1, 7) + 0.0  # Forces Float conversion
end

puts "Rational sum: #{sum_rational}"    # Exact: 1000/7
puts "Float sum: #{sum_float}"          # Approximate due to accumulated errors
puts "Difference: #{sum_rational.to_f - sum_float}"

Type promotion asymmetry creates confusion when developers expect symmetric coercion behavior. Operations don't always produce the same result type regardless of operand order, especially with custom numeric classes.

# Asymmetric coercion results
a = Complex(3, 4)
b = 2.5

result1 = a + b    # => (5.5+4i) - Complex result
result2 = b + a    # => (5.5+4i) - Also Complex, but through different path

# Custom classes can exhibit more dramatic asymmetry
class ModularInt
  attr_reader :value, :modulus
  
  def initialize(value, modulus = 256)
    @value = value % modulus
    @modulus = modulus
  end
  
  def coerce(other)
    [ModularInt.new(other, @modulus), self]
  end
  
  def +(other)
    if other.is_a?(ModularInt)
      ModularInt.new(@value + other.value, @modulus)
    else
      # This may not work as expected
      other + self  # Relies on other's implementation
    end
  end
end

m = ModularInt.new(200)
puts (m + 100).value      # => 44 (300 % 256)
puts (100 + m).value      # TypeError: Integer doesn't know about ModularInt

Coercion failures with non-numeric types produce confusing error messages. Ruby's error handling in coercion scenarios often points to missing methods rather than type incompatibility.

# Misleading error scenarios
begin
  result = "5" + 3  # String doesn't implement coerce properly
rescue TypeError => e
  puts "Expected error: #{e.message}"
end

begin
  result = 3 + "5"  # Integer#+ doesn't handle String
rescue TypeError => e
  puts "Error: #{e.message}"  # "no implicit conversion of String into Integer"
end

# Custom classes with incorrect coerce implementation
class BrokenNumeric
  def initialize(value)
    @value = value
  end
  
  def coerce(other)
    # Incorrect: returns wrong types
    [self, other]  # Should convert both to compatible types
  end
  
  def +(other)
    BrokenNumeric.new(@value + other)
  end
end

broken = BrokenNumeric.new(5)
# This will likely fail in unexpected ways

Infinite values and NaN propagation through coercion chains create hard-to-debug scenarios. These special float values maintain their properties across coercion but can lead to surprising results in complex calculations.

# NaN and Infinity coercion behavior
nan_float = Float::NAN
infinity = Float::INFINITY

# NaN infects all operations
puts Rational(1, 2) + nan_float    # => NaN
puts Complex(3, 4) + nan_float     # => (NaN+4i)

# Infinity behavior varies by operation
puts Rational(1, 2) + infinity     # => Infinity
puts Rational(1, 2) * infinity     # => Infinity
puts Rational(0, 1) * infinity     # => NaN (0 * Infinity = NaN)

# Complex infinity behavior
puts Complex(1, 2) + infinity      # => (Infinity+2i)
puts Complex::I * infinity         # => (0+Infinityi)

# Debugging NaN propagation
def trace_nan_source(value)
  return "Found NaN" if value.respond_to?(:nan?) && value.nan?
  return "Found Infinity" if value.respond_to?(:infinite?) && value.infinite?
  "Clean value: #{value}"
end

Custom coerce implementations often violate the expected contract, leading to asymmetric behavior or type conversion errors that surface far from the actual problem site.

# Contract violations in custom coerce
class ViolatesContract
  def initialize(value)
    @value = value
  end
  
  def coerce(other)
    # Violation: doesn't return array of two elements
    case other
    when Integer
      other.to_f  # Wrong return type
    when Float
      [other]     # Wrong array size
    else
      nil         # Wrong return type
    end
  end
end

# These violations cause confusing errors later in the operation chain
vc = ViolatesContract.new(42)
begin
  result = 3.14 + vc
rescue NoMethodError => e
  puts "Confusing error from contract violation: #{e.message}"
end

Reference

Core Coercion Methods

Method Parameters Returns Description
#coerce(other) other (Numeric) Array[Numeric, Numeric] Converts both operands to compatible types
Numeric.coerce(a, b) a, b (Numeric) Array[Numeric, Numeric] Handles coercion between any two numerics

Standard Type Coercion Matrix

Left Type Right Type Result Type Coercion Method
Integer Integer Integer None
Integer Float Float Float#coerce(Integer)
Integer Rational Rational Integer#coerce(Rational)
Integer Complex Complex Integer#coerce(Complex)
Float Integer Float Integer#coerce(Float)
Float Float Float None
Float Rational Float Rational#coerce(Float)
Float Complex Complex Float#coerce(Complex)
Rational Integer Rational Integer#coerce(Rational)
Rational Float Float Float#coerce(Rational)
Rational Rational Rational None
Rational Complex Complex Rational#coerce(Complex)
Complex Integer Complex Integer#coerce(Complex)
Complex Float Complex Float#coerce(Complex)
Complex Rational Complex Rational#coerce(Complex)
Complex Complex Complex None

Coercion Return Patterns

Coercion Call Return Value Description
4.5.coerce(3) [3.0, 4.5] Integer promoted to Float
Complex(1,2).coerce(5) [(5+0i), (1+2i)] Integer promoted to Complex
Rational(1,3).coerce(2) [Rational(2,1), Rational(1,3)] Integer promoted to Rational
Float::NAN.coerce(42) [42.0, NaN] Special float values preserved
Float::INFINITY.coerce(-10) [-10.0, Infinity] Infinity preserved in coercion

Custom Coercion Implementation Template

class CustomNumeric
  def coerce(other)
    case other
    when Integer
      [CustomNumeric.new(other), self]
    when Float
      [other, to_f]  # Promote to Float
    when Rational
      [other.to_f, to_f]  # Convert both to Float
    when Complex
      [other, to_c]  # Promote to Complex
    else
      raise TypeError, "#{other.class} can't be coerced into #{self.class}"
    end
  end
  
  def +(other)
    if other.is_a?(self.class)
      # Direct operation for same type
      perform_addition(other)
    else
      # Trigger coercion protocol
      a, b = other.coerce(self)
      a + b
    end
  end
end

Error Types and Causes

Error Type Common Cause Example
TypeError Non-numeric coercion "string".coerce(5)
NoMethodError Missing coerce method Object.new + 5
ArgumentError Invalid coercion result Returning non-array from coerce
ZeroDivisionError Division operations with zero Rational(1, 0)
FloatDomainError Invalid float operations Math.sqrt(-1) without Complex

Special Value Behaviors

Value Coercion Behavior Mathematical Properties
Float::NAN Preserved, infects results NaN op anything = NaN
Float::INFINITY Preserved, follows IEEE rules Infinity + finite = Infinity
0.0 vs 0 Type determines result precision Integer 0 vs Float 0.0
-0.0 Maintains signed zero 1.0 / -0.0 = -Infinity

Performance Characteristics

Operation Type Relative Cost Memory Allocation
Same type operations 1x (baseline) Minimal
Integer → Float 2-3x Single Float object
Integer → Rational 3-4x Numerator + denominator objects
Any → Complex 4-6x Real + imaginary objects
Custom coercion Variable Depends on implementation