Ruby 练习题(15 题)

Ruby 的优雅要靠动手才能真正掌握。这一页整理 15 道精选练习题,按难度分三级——基础 6 题(变量 / 运算 / 控制流 / 方法)、进阶 5 题(block / Proc / Hash)、实战 4 题(综合应用)。每题都包含题目描述、提示、可折叠的参考答案。

使用建议:先别急着展开答案。读题后自己打开本地 Ruby 环境(或 在线 Ruby)试着写,卡住了再看提示,实在写不出再对照参考答案。Ruby 的写法很多,你的写法可能比参考更好——能跑通、可读性强就是好代码。

基础(6 题):变量 / 运算 / 控制流 / 方法

这一组覆盖 Ruby 的基础语言元素,目标是用最短的代码解决一个小问题,训练"把思路翻译成代码"的能力。Ruby 一切皆对象,5 是 Integer、"str" 是 String,方法调用上没有"基本类型"和"包装类"的区别。

第 1 题:翻转字符串

题目:写一个方法 reverse_string(s),把传入的字符串翻转过来。比如 "hello" 翻转成 "olleh"。

提示:Ruby 字符串直接支持 .reverse;也可 s.chars.reverse.join 拆字再拼回。

参考答案
# 方法 1:内置 .reverse 最简洁(字符串、数组都支持)
def reverse_string(s)
  s.reverse
end

# 方法 2:手动用 chars + reverse + join(理解原理)
def reverse_manual(s)
  s.chars.reverse.join
end

puts reverse_string("hello")    # olleh
puts reverse_string("Ruby")      # ybuR
puts reverse_manual("world")     # dlrow
# 输出: olleh / ybuR / dlrow

第 2 题:判断素数

题目:写一个方法 prime?(n),判断正整数 n 是否为素数(只能被 1 和自己整除)。Ruby 习惯用 ? 结尾命名返回布尔的方法。

提示:小于 2 的都不是素数;只需检查从 2 到 sqrt(n) 的整除性;(2..Integer.sqrt(n)).none? { |i| n % i == 0 } 最优雅。

参考答案
def prime?(n)
  return false if n < 2
  # 只需检查到 sqrt(n),Integer::sqrt 是整数平方根,无浮点误差
  (2..Integer.sqrt(n)).none? { |i| n % i == 0 }
end

# none? { ... } 遍历集合,块内"全为 false"时返回 true
# 等价于 (2..Math.sqrt(n)).all? { |i| n % i != 0 }

puts prime?(2)    # true
puts prime?(7)    # true
puts prime?(10)   # false
puts prime?(1)    # false
# 输出: true / true / false / false

第 3 题:FizzBuzz

题目:用 puts 打印 1 到 15。3 的倍数打印 Fizz,5 的倍数打印 Buzz,同时是 3 和 5 的倍数打印 FizzBuzz,其余打印数字本身。

提示:if / elsif / else 的判断顺序很关键——必须先判断"同时是 3 和 5 的倍数"(即 15 的倍数);遍历用 (1..15).each { |i| ... }。

参考答案
# 经典题:3 的倍数 Fizz,5 的倍数 Buzz,15 的倍数 FizzBuzz
(1..15).each do |i|
  if i % 15 == 0           # 必须先判断 15,否则会被 3 或 5 截胡
    puts "FizzBuzz"
  elsif i % 3 == 0
    puts "Fizz"
  elsif i % 5 == 0
    puts "Buzz"
  else
    puts i
  end
end
# 输出:
# 1
# 2
# Fizz
# 4
# Buzz
# Fizz
# 7
# 8
# Fizz
# Buzz
# 11
# Fizz
# 13
# 14
# FizzBuzz

第 4 题:数组最大值

题目:写一个方法 find_max(arr),返回数组中的最大值。

提示:Ruby 数组直接调 .max 最简洁;也可用 inject 逐步比较,理解底层逻辑。

参考答案
# 方法 1:内置 .max 最直接
def find_max(arr)
  arr.max
end

# 方法 2:inject 逐次比较(理解原理)
def find_max_inject(arr)
  arr.inject { |m, x| x > m ? x : m }
end

puts find_max([3, 1, 4, 1, 5, 9, 2, 6])   # 9
puts find_max([-5, -2, -9])               # -2
puts find_max_inject([10, 20, 30])        # 30
puts [1, 2, 3].max                        # 3(直接对数组调 .max)
# 输出: 9 / -2 / 30 / 3

第 5 题:数组求和

题目:写一个方法 sum_arr(arr),返回数值数组的总和;空数组返回 0。

提示:Ruby 2.4+ 数组自带 .sum;函数式风格 inject(0, :+) 也很常见——:+ 是符号形式的 block 简写。

参考答案
# 方法 1:Ruby 2.4+ 数组内置 .sum(Rails 早已支持,纯 Ruby 也已加入)
def sum_arr(arr)
  arr.sum
end

# 方法 2:inject(:+) 函数式风格,符号作为 block 简写
def sum_inject(arr)
  arr.inject(0, :+)   # 0 是初始值,避免空数组返回 nil
end

puts sum_arr([1, 2, 3, 4, 5])    # 15
puts sum_inject([10, 20, 30])    # 60
puts sum_arr([])                 # 0(空数组 .sum 返回 0)
# 输出: 15 / 60 / 0

第 6 题:计算阶乘

题目:写一个方法 factorial(n),返回 n!。要求 factorial(0) == 1,且对负数抛 ArgumentError。

提示:最 Rubyist 的写法 (1..n).inject(1, :*)——n=0 时 Range 为空,inject 直接返回初始值 1;也可递归 n < 2 ? 1 : n * factorial(n-1)。

参考答案
# 方法 1:Range#inject 最 Rubyist 的写法
def factorial_range(n)
  raise ArgumentError, "阶乘不接受负数" if n < 0
  (1..n).inject(1, :*)   # n=0 时 Range 为空,inject 返回初始值 1
end

# 方法 2:递归(更贴近数学定义)
def factorial_rec(n)
  raise ArgumentError, "阶乘不接受负数" if n < 0
  n < 2 ? 1 : n * factorial_rec(n - 1)
end

puts factorial_range(5)   # 120
puts factorial_range(0)   # 1
puts factorial_rec(6)     # 720
# 输出: 120 / 1 / 720

进阶(5 题):block / Proc / Hash

这一组围绕 Ruby 最具特色的特性——block。block 是"匿名的代码块",几乎所有 Ruby 内置迭代方法都靠它驱动。理解 block 和 Proc 的转换、掌握 yield、链式调用 map/select,是写出地道 Ruby 代码的关键。

第 7 题:自定义 each 方法用 yield

题目:写一个 MyList 类,内部封装数组,通过 yield 暴露 each 方法,让外部能像普通数组一样遍历它。

提示:在 each 里遍历内部数组,每个元素 yield 给外部 block;再 include Enumerable 就能白嫖 map / select / max 等几十个方法。

参考答案
# 自己写一个 MyList 类,用 yield 暴露 each 方法,
# 让外部能像操作普通数组一样遍历它
class MyList
  include Enumerable   # 加这个 mixin 自动获得 map / select / max 等

  def initialize(items)
    @items = items
  end

  def each
    @items.each { |x| yield x }   # yield 把元素交给块
  end
end

list = MyList.new([1, 2, 3, 4, 5])
list.each { |x| print x, " " }     # 1 2 3 4 5
puts
puts list.map { |x| x * 2 }.inspect    # [2, 4, 6, 8, 10]
puts list.select(&:even?).inspect      # [2, 4]
puts list.max                          # 5
# 输出:
# 1 2 3 4 5
# [2, 4, 6, 8, 10]
# [2, 4]
# 5

第 8 题:map / select 链式处理

题目:给定 (1..10).to_a,链式调用 select + map 取出偶数再平方;再过滤长度 ≥ 5 的单词并转大写。

提示:select 过滤、map 变换,链式写法每步返回新数组;&:upcase 和 &:even? 是符号转 block 的简写,等价于 { |x| x.even? }。

参考答案
# 函数式风格:链式调用 map / select / reduce,逐步变换数据
nums = (1..10).to_a

# 1. 取偶数 → 再平方
result = nums.select { |n| n.even? }.map { |n| n * n }
puts result.inspect   # [4, 16, 36, 64, 100]

# 2. 过滤长度 >= 5 的单词 → 转大写
words = %w[ruby python go javascript rust]
long_upper = words.select { |w| w.length >= 5 }.map(&:upcase)
puts long_upper.inspect   # ["PYTHON", "JAVASCRIPT"]

# 3. 链式 + 求和:1..10 中偶数的平方和
sum_of_squares = nums.select(&:even?).map { |n| n * n }.sum
puts sum_of_squares       # 220(4+16+36+64+100)
# 输出: [4, 16, 36, 64, 100] / ["PYTHON", "JAVASCRIPT"] / 220

第 9 题:block 转 Proc 对象(&)

题目:写一个方法接收 block 并把它转成 Proc 对象存起来;再演示把 Proc 转回 block 调用 .map;最后用 &:upcase 把符号当 block 用。

提示:& 在方法参数列表里把 block 收成 Proc(def foo(&block)),在调用时把 Proc 或 Symbol 转成 block(map(&square) / map(&:upcase))。

参考答案
# & 前缀:把 block 转成 Proc 对象(或反之)
# 场景:想把"块"存起来、传给别的函数,就必须先转成 Proc

# 1. 方法参数加 & 把 block 收成 Proc
def capture(&block)
  puts block.class          # Proc
  puts block.call(10)       # 调用块
end
capture { |x| x * 3 }     # 输出 Proc / 30

# 2. Proc 转回 block(& 同样适用)
square = Proc.new { |x| x * x }
puts [1, 2, 3].map(&square).inspect   # [1, 4, 9]

# 3. & :symbol 把符号转 block(方法名简写)
puts %w[apple banana].map(&:upcase).inspect   # ["APPLE", "BANANA"]
puts [3, 1, 2].sort.reverse.inspect           # [3, 2, 1]
# 输出: Proc / 30 / [1, 4, 9] / ["APPLE", "BANANA"]

第 10 题:Hash 遍历与 group_by 分组

题目:给定一组员工(姓名 + 部门),用 group_by 按部门分组输出;再用 tally 统计数组中每个元素的出现次数。

提示:group_by { |u| u[:dept] } 返回 { dept => [users] };Array#tally(Ruby 2.7+)一行统计频次,等价于 group_by(&:itself).transform_values(&:size)。

参考答案
# group_by:按 block 返回值分组,返回 {key => [items]}
users = [
  { name: "小明", dept: "技术" },
  { name: "小红", dept: "市场" },
  { name: "小刚", dept: "技术" },
  { name: "小丽", dept: "市场" },
]

# 按部门分组
by_dept = users.group_by { |u| u[:dept] }
by_dept.each do |dept, members|
  puts "#{dept}(#{members.size}): #{members.map { |m| m[:name] }.join(', ')}"
end
# 输出:
# 技术(2): 小明, 小刚
# 市场(2): 小红, 小丽

# tally:Ruby 2.7+ 一行统计元素出现次数
puts %w[apple banana apple cherry apple banana].tally.inspect
# => {"apple"=>3, "banana"=>2, "cherry"=>1}
# 输出: 如上所示

第 11 题:类接收 block(initialize 或 yield)

题目:写一个类,既能用 yield 在方法里直接执行 block,也能在 initialize 接收 block 存起来延迟执行。

提示:方法内直接 yield(a, b) 把控制权交给块;initialize(&block) 把块转成 Proc 存到实例变量,之后 @block.call 触发。

参考答案
# 类方法或 initialize 都可以接收 block
# 1. 方法用 yield 直接调块
class Calculator
  def operate(a, b)
    yield(a, b)   # 把控制权交给调用方传入的块
  end
end

calc = Calculator.new
puts calc.operate(10, 3) { |x, y| x + y }   # 13
puts calc.operate(10, 3) { |x, y| x * y }   # 30

# 2. &block 把块存成实例变量,延迟执行
class Action
  def initialize(name, &block)
    @name = name
    @block = block    # block 转成 Proc 存起来
  end

  def run
    puts "[#@name] 开始"
    @block.call
    puts "[#@name] 结束"
  end
end

Action.new("备份") { puts "  正在复制文件..." }.run
# 输出: 13 / 30 + 备份任务的开始/复制/结束日志

实战(4 题):综合应用

这一组综合运用类、Hash、文件 IO、异常处理——做出几个接近真实场景的小程序。Ruby 的优雅在这些题目里会充分体现。

第 12 题:学生管理(class Student + array 排序)

题目:定义 Student 类(姓名 + 多科成绩 Hash,能算均分),管理多个学生并按均分降序排名。

提示:attr_accessor :name, :scores 自动生成读写器;sort_by(&:average).reverse 按均分降序;each_with_index(1) 从 1 开始带序号遍历。

参考答案
class Student
  attr_accessor :name, :scores     # 自动生成 getter/setter

  def initialize(name, scores)
    @name = name
    @scores = scores    # Hash: { 数学: 90, 语文: 80 }
  end

  def average
    @scores.values.sum.to_f / @scores.size
  end

  def to_s
    "#{name}(均分 #{format('%.1f', average)})"
  end
end

students = [
  Student.new("小明", { 数学: 90, 语文: 80 }),
  Student.new("小红", { 数学: 95, 语文: 88 }),
  Student.new("小刚", { 数学: 70, 语文: 100 }),
]

# 按均分降序排名(sort_by + reverse,或 sort { ... })
ranked = students.sort_by(&:average).reverse
puts "成绩排名:"
ranked.each_with_index(1) { |s, i| puts "  #{i}. #{s}" }
# 输出:
# 成绩排名:
#   1. 小红(均分 91.5)
#   2. 小明(均分 85.0)
#   3. 小刚(均分 85.0)

第 13 题:词频统计(Hash + group_by 或 tally)

题目:统计英文文本中每个单词的出现次数,按频次降序输出 Top 5。

提示:text.downcase.scan(/[a-z]+/) 提取单词并统一小写;tally 自动计数;sort_by { |_, c| -c } 按次数降序(用负号代替 reverse)。

参考答案
# 统计文本中每个单词的出现次数,按频次降序输出 Top N
def word_count(text)
  # 用正则提取所有英文单词,统一小写避免 The/the 分开计数
  words = text.downcase.scan(/[a-z]+/)
  # group_by + size 或直接 tally(Ruby 2.7+)自动计数
  words.tally
end

text = <<-TEXT
  The quick brown fox jumps over the lazy dog.
  The dog was not amused, but the fox just laughed.
TEXT

counts = word_count(text)
puts "Top 5 高频词:"
counts.sort_by { |_, c| -c }.first(5).each do |word, c|
  puts "  #{word}: #{c}"
end
# 输出:
# Top 5 高频词:
#   the: 4
#   dog: 2
#   fox: 2
#   quick: 1
#   brown: 1

第 14 题:文件读写(File.read / File.foreach)

题目:写代码完成四件事:(a) 整文件读;(b) 逐行读(大文件友好);(c) 覆盖写;(d) 追加写。

提示:File.read(path) 一次读全文;File.foreach(path) 逐行读(常量内存);File.write(path, content) 覆盖;File.open(path, "a") { |f| f.puts ... } 追加。

参考答案
# 1. 整文件读取(小文件最简单)
# content = File.read("article.txt")
# puts content

# 2. 逐行读取(大文件友好,常量内存)——File.foreach 不一次读全文件
# File.foreach("log.txt").with_index(1) do |line, i|
#   puts "#{i}: #{line.chomp}"
# end

# 3. 写入文件(覆盖 / 追加)
File.write("output.txt", "第一行\n")           # 覆盖写
File.open("output.txt", "a") { |f| f.puts "追加行" }   # 追加

# 4. 演示:用 StringIO 模拟文件(避免依赖真实文件)
require "stringio"
fake = StringIO.new("apple\nbanana\napple\n")
counts = Hash.new(0)
fake.each_line { |line| counts[line.chomp] += 1 }
puts counts.inspect
# 输出(同时 output.txt 写入两行):
# {"apple"=>2, "banana"=>1}

第 15 题:简易计算器(case when)

题目:实现一个支持 + - * / 的计算器方法 calc(a, op, b)。要求:除数为 0 抛 ZeroDivisionError,未知运算符抛 ArgumentError。

提示:case when "+"; ... when "-"; ... end 比 if/elsif 更清晰;除法时 a.to_f / b 才能得到浮点结果(整数除法会截断);rescue puts $!.message 优雅接住异常。

参考答案
def calc(a, op, b)
  case op           # case when 比 if/elsif 更适合多分支
  when "+" then a + b
  when "-" then a - b
  when "*" then a * b
  when "/"
    raise ZeroDivisionError, "除数不能为 0" if b == 0
    a.to_f / b      # to_f 让除法返回浮点
  else
    raise ArgumentError, "不支持的运算符: #{op}"
  end
end

# 命令行测试
puts calc(3, "+", 5)    # 8
puts calc(10, "/", 4)   # 2.5
puts calc(2, "*", 9)    # 18
puts calc(10, "-", 3)   # 7

# 异常演示(注释掉以免中断):
# puts calc(10, "/", 0) rescue puts "错误: #{$!.message}"
# 输出: 8 / 2.5 / 18 / 7
# $! 是最后抛出的异常(rescue 修饰符形式的隐式变量)

练习建议

不要只看答案。把每道题在本地或在线环境亲手敲一遍,改一改输入试试边界情况(空数组、负数、超长字符串、嵌套 Hash)。编程是手艺活,键盘上练出来的肌肉记忆比眼睛看十遍都管用。

试着扩展:做完第 12 题学生管理,能不能加上"按单科排名""导出 CSV"?做完第 13 题词频统计,能不能加上停用词过滤?做完第 15 题计算器,能不能加上幂运算和括号?每个扩展都是一次小型的真实开发。

更多练习资源:刷题去 Exercism Ruby;做小项目去 GitHub 找灵感;遇到不懂的先查 Ruby 官方文档(有中文版)。

← 上一篇 Ruby 实战项目

返回 Ruby 教程目录

✈️💬