CrackedRuby logo

CrackedRuby

Stack Implementation

Complete guide to implementing and using stack data structures in Ruby, covering Array-based stacks, custom implementations, and advanced patterns.

Standard Library Data Structures
4.1.3

Overview

Ruby provides multiple approaches for implementing stack data structures, with the Array class serving as the most common foundation. A stack follows Last-In-First-Out (LIFO) ordering, where elements are added and removed from the same end. Ruby's Array class includes native stack operations through #push and #pop methods, making stack implementation straightforward for most use cases.

The Array-based approach handles memory management automatically and provides good performance for typical stack operations. Ruby also supports custom stack implementations through class definition, allowing for specialized behavior, validation, or alternative storage mechanisms.

# Basic Array-based stack
stack = []
stack.push(1, 2, 3)
stack.pop  # => 3
stack      # => [1, 2]

Ruby stacks support any object type, including mixed types within the same stack. The language's dynamic typing allows stacks to hold integers, strings, hashes, or complex objects without type constraints.

# Mixed-type stack
mixed_stack = []
mixed_stack.push("hello", 42, {key: "value"})
mixed_stack.pop  # => {key: "value"}

Stack operations in Ruby are reference-based for objects, meaning pushing an object onto a stack stores a reference to that object rather than creating a copy. This behavior affects how modifications to objects impact stack contents.

# Reference behavior
hash = {count: 1}
stack = []
stack.push(hash)
hash[:count] = 2
stack.pop  # => {count: 2}

Basic Usage

Ruby's Array class provides the foundation for stack implementation through several key methods. The #push method adds one or more elements to the end of the array, while #pop removes and returns the last element. The #last method allows peeking at the top element without removal.

stack = []
stack.push(10)        # => [10]
stack.push(20, 30)    # => [10, 20, 30]
stack.last           # => 30
stack.pop            # => 30
stack                # => [10, 20]

The #empty? method checks whether the stack contains elements, while #size or #length returns the current number of elements. These methods enable safe stack operations and iteration control.

stack = [1, 2, 3]
stack.empty?   # => false
stack.size     # => 3

until stack.empty?
  puts stack.pop
end
# Output: 3, 2, 1

Ruby provides the << operator as an alias for single-element push operations. This operator offers syntactic convenience for building stacks through assignment chains or iterative operations.

stack = []
stack << 1 << 2 << 3
# Equivalent to: stack.push(1).push(2).push(3)
stack  # => [1, 2, 3]

Stack initialization can occur through Array literal syntax or by converting other enumerable objects. This flexibility allows stacks to be created from existing data structures or predefined element sets.

# Literal initialization
numbers = [1, 2, 3, 4, 5]

# From range
stack = (1..10).to_a
stack.pop  # => 10

# From string characters
chars = "hello".chars
chars.pop  # => "o"

Multiple elements can be pushed simultaneously, maintaining their order on the stack. This batch operation improves performance when adding several related elements and preserves the logical grouping of data.

operations = []
operations.push(:add, :multiply, :subtract)
operations.pop  # => :subtract
operations.pop  # => :multiply
operations.pop  # => :add

Ruby's stack operations integrate with block iteration patterns. The #pop method can be called within loops or used with enumerators to process stack elements in LIFO order.

stack = %w[first second third fourth]
results = []

4.times do
  results << stack.pop&.upcase
end

results  # => ["FOURTH", "THIRD", "SECOND", "FIRST"]

Advanced Usage

Custom stack implementations provide control over behavior, validation, and storage mechanisms beyond Ruby's standard Array approach. Class-based stacks can enforce type constraints, implement size limits, or add specialized operations.

class TypedStack
  def initialize(type)
    @type = type
    @elements = []
  end

  def push(element)
    unless element.is_a?(@type)
      raise TypeError, "Expected #{@type}, got #{element.class}"
    end
    @elements.push(element)
    self
  end

  def pop
    @elements.pop
  end

  def peek
    @elements.last
  end

  def empty?
    @elements.empty?
  end

  def size
    @elements.size
  end
end

string_stack = TypedStack.new(String)
string_stack.push("hello").push("world")
string_stack.pop  # => "world"

Bounded stacks implement maximum capacity constraints, preventing unlimited growth and controlling memory usage. These implementations raise exceptions or reject additions when capacity is reached.

class BoundedStack
  attr_reader :capacity, :size

  def initialize(capacity)
    @capacity = capacity
    @elements = []
  end

  def push(element)
    if @elements.size >= @capacity
      raise "Stack overflow: capacity #{@capacity} exceeded"
    end
    @elements.push(element)
    self
  end

  def pop
    @elements.pop
  end

  def full?
    @elements.size == @capacity
  end

  def remaining_capacity
    @capacity - @elements.size
  end
end

limited_stack = BoundedStack.new(3)
limited_stack.push(1).push(2).push(3)
# limited_stack.push(4)  # Would raise exception

Stack composition enables complex data structures built from multiple stack instances. This pattern supports implementing algorithms requiring multiple stacks or creating stack-based state machines.

class DualStack
  def initialize
    @primary = []
    @secondary = []
  end

  def push_primary(element)
    @primary.push(element)
  end

  def push_secondary(element)
    @secondary.push(element)
  end

  def transfer_top
    return nil if @primary.empty?
    @secondary.push(@primary.pop)
  end

  def merge_all
    result = []
    until @primary.empty? && @secondary.empty?
      result << (@primary.empty? ? @secondary.pop : @primary.pop)
    end
    result
  end
end

dual = DualStack.new
dual.push_primary(1)
dual.push_secondary(2)
dual.transfer_top
dual.merge_all  # => [2, 1]

Stacks can implement enumerable behavior by including Ruby's Enumerable module, enabling standard iteration methods while maintaining stack semantics for modification operations.

class EnumerableStack
  include Enumerable

  def initialize
    @elements = []
  end

  def push(element)
    @elements.push(element)
    self
  end

  def pop
    @elements.pop
  end

  def each
    return enum_for(:each) unless block_given?
    
    @elements.reverse_each { |element| yield element }
  end

  def empty?
    @elements.empty?
  end
end

stack = EnumerableStack.new
stack.push(1).push(2).push(3)
stack.select(&:odd?)  # => [3, 1]
stack.map { |x| x * 2 }  # => [6, 4, 2]

Metaprogramming can create domain-specific stack operations or implement stack variants with specialized behavior. Method generation and module inclusion provide dynamic stack enhancement capabilities.

module StackOperations
  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def define_typed_operation(name, type)
      define_method("push_#{name}") do |element|
        unless element.is_a?(type)
          raise TypeError, "Expected #{type} for #{name}"
        end
        push(element)
      end

      define_method("pop_#{name}") do
        result = pop
        return result if result.nil? || result.is_a?(type)
        
        push(result)  # Put it back
        nil
      end
    end
  end
end

class FlexibleStack < Array
  include StackOperations

  define_typed_operation :number, Numeric
  define_typed_operation :string, String

  alias_method :push, :<<
end

stack = FlexibleStack.new
stack.push_number(42)
stack.push_string("hello")
stack.pop_string  # => "hello"
stack.pop_number  # => 42

Error Handling & Debugging

Stack underflow represents the most common error condition, occurring when pop operations are attempted on empty stacks. Ruby's Array returns nil for empty pop operations, but custom implementations may raise exceptions for stricter error handling.

def safe_stack_operation
  stack = []
  result = stack.pop
  
  if result.nil?
    puts "Warning: attempted pop on empty stack"
    return :empty_stack
  end
  
  result
end

# Alternative approach with custom exception
class StackUnderflowError < StandardError; end

class SafeStack
  def initialize
    @elements = []
  end

  def pop
    raise StackUnderflowError, "Cannot pop from empty stack" if @elements.empty?
    @elements.pop
  end

  def safe_pop
    @elements.empty? ? nil : @elements.pop
  end
end

Debugging stack operations requires tracking element order and state changes. Ruby's #inspect method reveals stack contents, while custom logging can trace operation sequences.

class DebuggingStack
  def initialize(name = "stack")
    @name = name
    @elements = []
    @operations = []
  end

  def push(element)
    @elements.push(element)
    log_operation(:push, element)
    self
  end

  def pop
    element = @elements.pop
    log_operation(:pop, element)
    element
  end

  def debug_info
    {
      name: @name,
      size: @elements.size,
      contents: @elements.dup,
      recent_operations: @operations.last(5)
    }
  end

  private

  def log_operation(op, element)
    @operations << {
      operation: op,
      element: element,
      timestamp: Time.now,
      size_after: @elements.size
    }
  end
end

debug_stack = DebuggingStack.new("test_stack")
debug_stack.push(1).push(2).push(3)
debug_stack.pop
puts debug_stack.debug_info

Stack overflow detection prevents unbounded growth in recursive or iterative operations. Monitoring stack size and implementing limits helps identify infinite recursion or runaway processes.

class MonitoredStack
  MAX_SAFE_SIZE = 10000

  def initialize
    @elements = []
    @max_size_reached = 0
  end

  def push(element)
    if @elements.size >= MAX_SAFE_SIZE
      raise "Stack size limit exceeded: #{MAX_SAFE_SIZE}"
    end

    @elements.push(element)
    @max_size_reached = [@max_size_reached, @elements.size].max
    
    warn "Large stack detected: #{@elements.size} elements" if @elements.size > 1000
    
    self
  end

  def statistics
    {
      current_size: @elements.size,
      max_size_reached: @max_size_reached,
      memory_estimate: @elements.size * 24  # rough estimate in bytes
    }
  end
end

Testing stack implementations requires validating both successful operations and error conditions. Comprehensive tests cover edge cases, boundary conditions, and exception handling.

# Example test structure (not using specific testing framework)
def test_stack_operations
  stack = []
  
  # Test empty stack behavior
  assert_equal nil, stack.pop
  assert_equal true, stack.empty?
  
  # Test push/pop cycle
  stack.push(1)
  assert_equal false, stack.empty?
  assert_equal 1, stack.size
  
  result = stack.pop
  assert_equal 1, result
  assert_equal true, stack.empty?
  
  # Test multiple elements
  stack.push(1, 2, 3)
  assert_equal 3, stack.pop
  assert_equal 2, stack.pop
  assert_equal 1, stack.pop
  
  puts "All tests passed"
end

def assert_equal(expected, actual)
  raise "Expected #{expected}, got #{actual}" unless expected == actual
end

Performance & Memory

Array-based stacks in Ruby provide O(1) amortized time complexity for push and pop operations through dynamic array resizing. However, occasional O(n) resize operations occur when the underlying storage capacity is exceeded.

require 'benchmark'

def benchmark_stack_operations(size)
  Benchmark.bm(20) do |x|
    # Array-based stack (standard approach)
    x.report("Array push/pop:") do
      stack = []
      size.times { |i| stack.push(i) }
      size.times { stack.pop }
    end
    
    # Pre-allocated array
    x.report("Pre-allocated:") do
      stack = Array.new(size)
      index = 0
      size.times { |i| stack[index] = i; index += 1 }
      size.times { index -= 1; stack[index] }
    end
  end
end

# benchmark_stack_operations(100_000)

Memory usage patterns differ between Array-based stacks and custom implementations. Arrays allocate extra capacity for growth, while linked-list based stacks allocate memory per element without over-allocation.

class LinkedStack
  Node = Struct.new(:value, :next)
  
  def initialize
    @top = nil
    @size = 0
  end

  def push(element)
    @top = Node.new(element, @top)
    @size += 1
    self
  end

  def pop
    return nil unless @top
    
    value = @top.value
    @top = @top.next
    @size -= 1
    value
  end

  def size
    @size
  end

  def memory_nodes
    count = 0
    current = @top
    while current
      count += 1
      current = current.next
    end
    count
  end
end

# Memory comparison
array_stack = []
linked_stack = LinkedStack.new

1000.times { |i| array_stack.push(i) }
1000.times { |i| linked_stack.push(i) }

puts "Array capacity: #{array_stack.size}"  # Actual elements
puts "Linked nodes: #{linked_stack.memory_nodes}"  # Exact node count

Large object stacks require careful memory management to prevent excessive memory usage. Object references consume additional memory beyond the stack storage itself.

class LargeObject
  def initialize(size)
    @data = Array.new(size, "x" * 100)  # Large data payload
  end
  
  def memory_size
    @data.size * @data.first.bytesize
  end
end

def analyze_stack_memory
  stack = []
  
  # Add large objects
  10.times do |i|
    obj = LargeObject.new(1000)
    stack.push(obj)
    puts "Stack size: #{stack.size}, Est. memory: #{obj.memory_size * stack.size} bytes"
  end
  
  # Clear stack to free memory
  stack.clear
  GC.start  # Suggest garbage collection
end

Stack performance optimization involves choosing appropriate data structures based on usage patterns. Frequent size queries benefit from cached size tracking, while memory-constrained environments favor minimal allocation strategies.

class OptimizedStack
  def initialize
    @elements = []
    @size = 0
  end

  def push(element)
    @elements[@size] = element
    @size += 1
    expand_capacity if @size > @elements.size
    self
  end

  def pop
    return nil if @size == 0
    
    @size -= 1
    element = @elements[@size]
    @elements[@size] = nil  # Clear reference for GC
    
    shrink_capacity if should_shrink?
    element
  end

  def size
    @size  # O(1) cached size
  end

  private

  def expand_capacity
    new_capacity = [@elements.size * 2, 8].max
    @elements.concat(Array.new(new_capacity - @elements.size))
  end

  def shrink_capacity
    return if @elements.size <= 8
    
    target_size = [@size * 2, 8].max
    @elements = @elements[0, target_size]
  end

  def should_shrink?
    @elements.size > 8 && @size < @elements.size / 4
  end
end

Common Pitfalls

Reference sharing between stack elements creates unexpected behavior when objects are modified after being pushed onto the stack. Changes to referenced objects affect all stack locations containing those references.

# Problematic reference sharing
shared_hash = {value: 1}
stack1 = []
stack2 = []

stack1.push(shared_hash)
stack2.push(shared_hash)

shared_hash[:value] = 999

# Both stacks now contain the modified object
stack1.pop  # => {value: 999}
stack2.pop  # => {value: 999}

# Safe approach: duplicate objects when needed
original = {value: 1}
stack1.push(original.dup)
stack2.push(original.dup)

original[:value] = 999
stack1.pop  # => {value: 1}
stack2.pop  # => {value: 1}

Array mutation during stack operations can corrupt stack state when the underlying Array is modified through methods other than push and pop. Direct index assignment or bulk operations bypass stack semantics.

# Dangerous array mutations
stack = [1, 2, 3]
stack[1] = 999  # Violates stack ordering
stack.sort!     # Completely reorders elements

# Stack pop now returns unexpected values
stack.pop  # => 999 (should be 3)

# Safe approach: encapsulate array access
class ProtectedStack
  def initialize
    @elements = []
  end

  def push(element)
    @elements.push(element)
  end

  def pop
    @elements.pop
  end

  # No direct access to underlying array
  private attr_reader :elements
end

Empty stack operations create silent failures when using Array's built-in behavior. The nil return value from popping empty stacks may propagate through code without obvious error indication.

# Silent failure propagation
def process_stack(stack)
  result = []
  
  # This continues even when stack becomes empty
  10.times do
    element = stack.pop  # Returns nil when empty
    result << element.upcase  # NoMethodError on nil
  end
  
  result
end

# Defensive approach
def safe_process_stack(stack)
  result = []
  
  until stack.empty?
    element = stack.pop
    result << element.upcase if element
  end
  
  result
end

Stack size assumptions lead to errors when code expects specific stack depths without validation. Operations that assume minimum stack contents fail when stacks are smaller than expected.

# Dangerous size assumptions
def swap_top_two(stack)
  # Assumes stack has at least 2 elements
  first = stack.pop
  second = stack.pop
  
  stack.push(first)   # first goes back on top
  stack.push(second)  # second goes on top
end

# Safe implementation with validation
def safe_swap_top_two(stack)
  return false if stack.size < 2
  
  first = stack.pop
  second = stack.pop
  
  stack.push(first)
  stack.push(second)
  
  true
end

Thread safety assumptions cause race conditions when multiple threads access the same stack without synchronization. Array operations are not atomic, leading to corrupted state in concurrent environments.

# Thread safety problem
shared_stack = []

threads = 10.times.map do |i|
  Thread.new do
    100.times { shared_stack.push(i) }
    100.times { shared_stack.pop }
  end
end

threads.each(&:join)
# shared_stack.size is unpredictable due to race conditions

# Thread-safe approach
require 'thread'

class ThreadSafeStack
  def initialize
    @elements = []
    @mutex = Mutex.new
  end

  def push(element)
    @mutex.synchronize { @elements.push(element) }
    self
  end

  def pop
    @mutex.synchronize { @elements.pop }
  end

  def size
    @mutex.synchronize { @elements.size }
  end
end

Reference

Core Stack Operations

Method Parameters Returns Description
Array#push(obj, ...) Objects to add Array Adds elements to end of array
Array#pop None Object or nil Removes and returns last element
Array#<<(obj) Single object Array Adds one element to end
Array#last None Object or nil Returns last element without removal
Array#empty? None Boolean Checks if array contains no elements
Array#size None Integer Returns number of elements
Array#length None Integer Alias for size
Array#clear None Array Removes all elements

Stack State Methods

Method Parameters Returns Description
Array#first None Object or nil Returns first element (bottom of stack)
Array#include?(obj) Object to find Boolean Checks if object exists in stack
Array#index(obj) Object to find Integer or nil Returns position of object from bottom
Array#rindex(obj) Object to find Integer or nil Returns position of object from top

Stack Conversion Methods

Method Parameters Returns Description
Array#to_a None Array Returns copy as Array
Array#dup None Array Creates shallow copy
Array#clone None Array Creates copy with same object_id relations
Array#reverse None Array Returns new array with reversed order
Array#reverse! None Array Reverses array in place

Common Stack Patterns

# LIFO iteration
until stack.empty?
  element = stack.pop
  process(element)
end

# Peek without removal
top_element = stack.last

# Conditional pop
result = stack.pop if stack.size > minimum_size

# Batch operations
stack.push(*array_of_elements)

# Stack copying
backup_stack = stack.dup

# Size-limited push
def limited_push(stack, element, max_size)
  stack.push(element) if stack.size < max_size
end

Stack Implementation Templates

Basic Custom Stack:

class BasicStack
  def initialize
    @elements = []
  end
  
  def push(element)
    @elements.push(element)
    self
  end
  
  def pop
    @elements.pop
  end
  
  def peek
    @elements.last
  end
  
  def empty?
    @elements.empty?
  end
  
  def size
    @elements.size
  end
end

Thread-Safe Stack:

class ThreadSafeStack
  def initialize
    @elements = []
    @mutex = Mutex.new
  end
  
  def push(element)
    @mutex.synchronize { @elements.push(element) }
    self
  end
  
  def pop
    @mutex.synchronize { @elements.pop }
  end
  
  def size
    @mutex.synchronize { @elements.size }
  end
end

Error Conditions

Condition Array Behavior Custom Implementation Options
Pop from empty stack Returns nil Raise exception or return nil
Push to full bounded stack N/A Raise exception or reject silently
Invalid element type Accepts any object Raise TypeError
Concurrent access Undefined behavior Use Mutex for synchronization

Performance Characteristics

Operation Time Complexity Space Complexity Notes
push O(1) amortized O(1) Occasional O(n) for resize
pop O(1) O(1) Always constant time
peek/last O(1) O(1) No memory allocation
size O(1) O(1) Cached in Array implementation
empty? O(1) O(1) Simple size comparison