Ruby 中的乘法 table

multiplication table in Ruby

你能帮帮我吗?

我正在 Ruby 中解决一个练习,结果必须是这样的:

it 'multiplication table de 1 a 10' do
    expect(ArrayUtils.tabuada(10)).to eq [
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
      [2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
      [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
      [4, 8, 12, 16, 20, 24, 28, 32, 36, 40],
      [5, 10, 15, 20, 25, 30, 35, 40, 45, 50],
      [6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
      [7, 14, 21, 28, 35, 42, 49, 56, 63, 70],
      [8, 16, 24, 32, 40, 48, 56, 64, 72, 80],
      [9, 18, 27, 36, 45, 54, 63, 72, 81, 90],
      [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
    ]
  end

  it 'multiplication table de 1 a 3' do
    expect(ArrayUtils.tabuada(3)).to eq [
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
      [2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
      [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
    ]
  end

我的代码:

def self.tabuada(n)
  (1..n).each do |element|
    (1..n-1).each { |item| print "#{element * item}, " }
  puts  element * n
  end
end
tabuada(3)

结果是:

1, 2, 3

2、4、6

3、6、9

有什么建议吗?

您的代码中有两个问题:

  1. 测试希望您return一个嵌套数组,而不是打印结果
  2. 内循环总是结束一个10,它根本不依赖于n

我会从这样的事情开始:

def self.tabuada(n)
  (1..n).map do |n|
    (1..10).map do |i|
      n * i
    end
  end
end

或者:

def self.tabuada(n)
  (1..n).map { |element| Array.new(10) { |i| (i + 1) * element } }
end

好的错误消息对于好的测试框架非常重要。

错误消息应该会指导您找到解决方案。

看起来您正在使用 RSpec,它确实有很好的错误消息。那么,让我们看看我们得到的错误信息:

expected: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], [3, 6, 9, 12, 15, 18, 21, 24,...56, 64, 72, 80], [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]]
     got: 1..10
     
     (compared using ==)

因此,错误消息告诉我们 RSpec 预期会找到一个数组,但您的方法 return 却找到了一个范围。这很有趣,所以让我们看看 做了什么 你的方法 return.

好吧,在没有显式 return 关键字的情况下,方法定义主体的计算结果为在方法定义主体内计算的最后一个表达式的值。在您的情况下,方法内部只有一个表达式,因此该表达式的值为 returned:

(1..n).each do |element|
  (1..n-1).each { |item| print "#{element * item}, " }
  puts  element * n
end

(1..n).each 的 return 值是多少?我们可以简单地查看文档,我们看到 Range#each returns self,即它被调用的对象。因此,您的方法的 return 值将 始终 只是范围 1..n,而不是测试期望的数组数组。

[旁注:我做了,事实上,不是查找什么Range#each return。 Every each 实现 every 集合 always returns self,这是 each 合同的一部分。这是您在 Ruby.]

中编程时随着时间的推移学到的东西之一

让我们做最简单的事情,我们可以更改消息。这就是我们想要做的。我们不想修复所有问题,也不想迈出一大步。我们只想做一个 微小的 更改来更改错误消息。

如果我们查看可用的方法,我们会发现方法 Enumerable#map 转换元素,returns 是转换元素的数组。这实际上听起来不错,因为将一些数字转换为 table 相乘的数字正是我们想要做的。

所以,我们只需将 each 更改为 map。仅此而已:

(1..n).map do |element|
  (1..n-1).each { |item| print "#{element * item}, " }
  puts  element * n
end

然后我们看看会发生什么:

got: [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil]

已经好了。我们期待一个数组,我们得到了一个数组。只是错误的数组,但我们已经有了正确的类型!之前,我们获取的是一个范围,现在我们获取的是一个数组。

为什么这个数组充满了 nil?我们要数字!如果我们再次查看 map 的文档,我们会看到块的值用于填充数组。那么,区块的价值是多少?

和上面一样,整个块的值是块内计算的最后一个表达式的值。块内的最后一个表达式是

puts element * n

如果我们查看 Kernel#puts 的文档,我们会发现确实如此 return nil!但我们想要一个数字。好吧,我们已经有了一个数字: element * n 是一个数字!因此,与其 打印 数字(问题描述一开始就没有要求)和 returning nil,不如 return号码。换句话说,只需删除 puts:

(1..n).map do |element|
  (1..n-1).each { |item| print "#{element * item}, " }
  element * n
end

这是结果:

got: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

再一次,更近了一步。我们想要一个数组,我们有一个数组。我们想要数字,我们有数字。我们真正想要的不是数字数组,而是 array-of-arrays-of-numbers。如果你仔细看,你会发现我们这里实际上是我们期望的 array-of-arrays-of-numbers 的 last array (或者换一种说法,它是最后一行table).

嘿,但我们已经知道可以生成数组的东西:map 可以做到!将外部 each 变成一个映射给了我们一个数组。自然地,在 map 中嵌套 map 会给我们嵌套数组。那么,让我们这样做吧:

(1..n).map do |element|
  (1..n-1).map { |item| print "#{element * item}, " }
  element * n
end

嗯……实际上,这并没有改变任何东西。这是有道理的,真的:块的值是最后一个表达式的值,最后一个表达式是 element * n 之前的 map 确实 return 一个数组,但是我们没有对那个数组做任何事情,我们只是把它扔掉了。

那么,让我们做个实验,删除 element * n 看看会发生什么:

got: [[nil, nil, nil, nil, nil, nil, nil, nil, nil], [nil, nil, nil, nil, nil, nil, nil, nil, nil], [nil, ... nil], [nil, nil, nil, nil, nil, nil, nil, nil, nil], [nil, nil, nil, nil, nil, nil, nil, nil, nil]]

我想,进一步,退一步。我们想要一个 array-of-arrays-of-numbers,最初我们有一个 array-of-numbers 并且缺少“嵌套”部分,现在我们有一个嵌套的 array-of-arrays,但是我们丢失了数字。

但是我们已经知道原因了,因为我们已经弄清楚了一次:Kernel#print returns nil,所以我们删除它:

(1..n).map do |element|
  (1..n-1).map { |item| "#{element * item}, " }
end

这是结果:

expected: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], [3, 6, 9, 12, 15, 18, 21, 24,...56, 64, 72, 80], [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]]
     got: [["1, ", "2, ", "3, ", "4, ", "5, ", "6, ", "7, ", "8, ", "9, "], ["2, ", "4, ", "6, ", "8, ", "10, "..., "63, ", "72, ", "81, "], ["10, ", "20, ", "30, ", "40, ", "50, ", "60, ", "70, ", "80, ", "90, "]]

嘿,这实际上非常接近!我们想要一个 array-of-arrays-of-numbers,并且我们有一个 array-of-arrays-of-strings-with-numbers-in-m。让我们看看当我们删除字符串并只保留数字时会发生什么:

(1..n).map do |element|
  (1..n-1).map { |item| element * item }
end
expected: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], [3, 6, 9, 12, 15, 18, 21, 24,...56, 64, 72, 80], [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]]
     got: [[1, 2, 3, 4, 5, 6, 7, 8, 9], [2, 4, 6, 8, 10, 12, 14, 16, 18], [3, 6, 9, 12, 15, 18, 21, 24, 27], [4... 32, 40, 48, 56, 64, 72], [9, 18, 27, 36, 45, 54, 63, 72, 81], [10, 20, 30, 40, 50, 60, 70, 80, 90]]

酷!我们只算了一个太低了。那是因为我们原本有一个最后一个数字的特例,我们只是删除了那个特例。正如我们现在所看到的,实际上没有特殊情况的原因,所以我们可以将最后一个数字合并到我们的正常代码流中:

(1..n).map do |element|
  (1..n).map { |item| element * item }
end

恭喜!两个测试中的第一个通过了!我们现在只需要担心第二个测试,它给出了这个错误:

expected: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]]
     got: [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

哦,我明白这是怎么回事了。在第一个测试中,乘法 table 正好 恰好 是平方,所以我们实现了一个平方乘法 table。但实际上,行 总是 应该是 10 列宽,而不考虑 n,而我们只是假设它们是 n 宽,因为在第一个测试中, n 刚好是 10.

但这很容易解决:

(1..n).map do |element|
  (1..10).map { |item| element * item }
end

现在所有测试都通过了!

你会注意到我们现在几乎没有输出。这是一个标准的编程哲学:如果一切顺利,就不要打印任何东西。只有在出现问题时才打印一些东西。如果你在事情进展的时候打印太多正确,那么当事情出错时就很难发现。此外,您 de-sensitize 您的用户,他们只是开始忽略打印的内容,因为他们对此不感兴趣。

这就是为什么 RSpec 默认情况下只为每个通过的测试打印一个绿点,最后只打印一个单行摘要,而 打印大消息当出现问题时:

..

Finished in 0.00701 seconds (files took 0.15231 seconds to load)
2 examples, 0 failures

当然,还有很多其他的写法。我想向您展示的是如何修复您已经编写的内容,只需盲目地遵循 RSpec 出色的错误消息,然后简单地采取一些微小的步骤将错误消息更改为不同的内容。您甚至没有尝试 修复 错误,您只是在尝试 更改 错误以更接近您的目标。

我们真的不需要认真思考。这些错误几乎告诉我们该怎么做。这是好的错误消息和好的测试的标志。

您甚至可以在根本没有代码的情况下执行此操作!有一种称为 "Test-Driven Development" (TDD) 的方法,其核心思想是在编写第一行代码之前 编写测试 ,然后通过简单地“倾听”来开发代码错误”。

在这种情况下,它可能看起来有点像这样。我们从一个空文件开始,我们得到这个错误:

NameError:
  uninitialized constant ArrayUtils

请记住,我们只是在尝试做“最简单的事情来更改错误消息”。报错信息说ArrayUtils常量没有初始化,所以我们只初始化它,仅此而已:

ArrayUtils = nil
NoMethodError:
  undefined method `tabuada' for nil:NilClass

好的,现在它告诉我们 NilClass 没有名为 tabuada 的方法。我们所做的就是更改错误消息的最愚蠢的事情:我们将该方法添加到 NilClass:

class NilClass
  def tabuada
  end
end

这是愚蠢的吗?是的,当然是!但我们并不是想在这里变得聪明。事实上,我们非常想不聪明。调试代码很难,比编写代码更难。这意味着如果您使代码尽可能聪明,根据定义您还不够聪明,无法调试它!所以,让我们暂时坚持下去。

ArgumentError:
  wrong number of arguments (given 1, expected 0)

好吧,让我们给它一个:

def tabuada(_)
end

现在,我们不再从 Ruby 获取错误,而是从 RSpec 获取测试失败。这已经是很好的一步了:

expected: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [2, 4, 6, 8, 10, 12, 14, 16, 18, 20], [3, 6, 9, 12, 15, 18, 21, 24,...56, 64, 72, 80], [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]]
     got: nil

所以,我们 return 弄错了。我们可以通过 return 测试要求我们做的事情来轻松解决这个问题:

def tabuada(_)
  [
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
    [2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
    [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
    [4, 8, 12, 16, 20, 24, 28, 32, 36, 40],
    [5, 10, 15, 20, 25, 30, 35, 40, 45, 50],
    [6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
    [7, 14, 21, 28, 35, 42, 49, 56, 63, 70],
    [8, 16, 24, 32, 40, 48, 56, 64, 72, 80],
    [9, 18, 27, 36, 45, 54, 63, 72, 81, 90],
    [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
  ]
end

显然,这通过了第一个测试,但没有通过第二个测试。现在,我们可以添加这样的条件表达式:

def tabuada(_)
  if _ == 10
    [
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
      [2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
      [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
      [4, 8, 12, 16, 20, 24, 28, 32, 36, 40],
      [5, 10, 15, 20, 25, 30, 35, 40, 45, 50],
      [6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
      [7, 14, 21, 28, 35, 42, 49, 56, 63, 70],
      [8, 16, 24, 32, 40, 48, 56, 64, 72, 80],
      [9, 18, 27, 36, 45, 54, 63, 72, 81, 90],
      [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
    ]
  else
    [
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
      [2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
      [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]
    ]
  end
end

不过,要清楚这不是精神问题。如果有更多测试,我们将不得不为每个数字添加一个新子句。特别是,这应该适用于无限多的数字。

因此,我们将重新考虑我们的方法。我们回去吧。我们只需要一个数组,所以让我们从一个数组开始:

def tabuada(_)
  []
end

作为第一步,我们的数组需要有与参数一样多的行。如果要创建具有一定数量元素的数组,可以使用 Array::new:

def tabuada(_)
  Array.new(_)
end
got: [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil]

长度不错。但内容不是。内容也应该是一个数组:

def tabuada(_)
  Array.new(_) { Array.new(10) }
end
got: [[nil, nil, nil, nil, nil, nil, nil, nil, nil, nil], [nil, nil, nil, nil, nil, nil, nil, nil, nil, ni...l, nil, nil, nil, nil, nil, nil, nil, nil, nil], [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil]]

太好了,结果数组已经有了我们想要的形状,现在只是少了内容。 Array::new 生成块的索引,因此我们可以使用它来创建内容:

def tabuada(_)
  Array.new(_) { |a| Array.new(10) { |b| a * b }}
end
got: [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [0, 2, 4, 6, 8, 10, 12, 14, 16, 18],...35, 42, 49, 56, 63], [0, 8, 16, 24, 32, 40, 48, 56, 64, 72], [0, 9, 18, 27, 36, 45, 54, 63, 72, 81]]

我们很接近了!正如我上面所说,Array::new 产生索引,但是 Ruby 数组索引的范围从 0 到 size-1,而不是 1 到 size,所以剩下要做的就是在索引上加 1,或者或者,使用后继者:

def tabuada(_)
  Array.new(_) { |a| Array.new(10) { |b| a.succ * b.succ }}
end

现在,我们所有的测试都通过了!

现在我们已经通过了测试 c这是一个非常重要的步骤:Refactoring。重构意味着:

  • 测试通过时,
  • 小,well-defined,可逆步骤,
  • 更改代码结构而不更改其行为
  • 让它看起来像是您从一开始就想出了整个设计。

我们的第一个重构将是 Rename Parameter Refactoring。当我们引入参数时,我们忽略了它,所以我们给它起了一个被忽略参数的标准名称:_。但这是一个糟糕的名字,所以我们将其重命名为 n:

def tabuada(n)
  Array.new(n) { |a| Array.new(10) { |b| a.succ * b.succ }}
end

我们 运行 再次测试以确保我们没有破坏任何东西。

我们的下一个重构将是Move Method Refactoring。我们非常愚蠢地按照错误消息告诉我们它无法在 nil 上找到方法 tabuada,所以我们做了最简单的事情,将 tabuada 添加到 nil。但是,这没有多大意义。

相反,我们想将它添加到 ArrayUtils在那一点 刚好是 nil

这意味着首先,我们需要将 ArrayUtils 更改为其他内容。大多数 Ruby 程序员可能会使用 module,但对于不会混合使用且仅用作单例方法容器的东西,我个人实际上更喜欢空 BasicObject。所以,让我们这样做,然后将方法移动到它:

ArrayUtils = BasicObject.new

def ArrayUtils.tabuada(n)
  Array.new(n) {|a| Array.new(10) {|b| a.succ * b.succ } }
end

我们可以稍微缩短为这样的:

def (ArrayUtils = BasicObject.new).tabuada(n)
  Array.new(n) {|a| Array.new(10) {|b| a.succ * b.succ } }
end

请注意,在这两种情况下,在第一个示例中我们从您的失败代码开始并修复它,在第二个示例中我们从一个空文件开始,我们总是以小而简单的步骤进行工作:

  1. 阅读错误信息。
  2. 理解错误。
  3. 对代码进行最简单、最小、最愚蠢的更改,仅更改错误消息的一个方面。
  4. 阅读新的错误信息。
  5. 理解错误。
  6. 检查新错误是向前迈出的一步还是至少是向侧面迈出的一步。
  7. 如果不是,请还原更改并尝试不同的方法。
  8. 否则,转到#3 并重复直到测试通过。

仅当测试通过时:重构使它看起来好像我们从一开始就知道我们要去哪里。

测试确保我们一直在前进。小步骤确保我们始终了解我们在做什么,当出现问题时,我们知道问题只能出在我们更改的一小段代码中。他们还确保当我们必须恢复时,我们只会损失几秒钟的工作。