如何使用TDD实现复杂算法

4

我想用TDD在Java中实现一个相当复杂的算法,这个自然语言翻译算法称为 堆栈解码

当我尝试实现时,我能够编写并修复一些简单的测试案例(空翻译、一个单词等),但我无法按照下面描述的步骤逐步撰写大量的算法。我的意思是,我无法找到实现算法的方向。

以下是该算法的伪代码:

1: place empty hypothesis into stack 0
2: for all stacks 0...n − 1 do
3: for all hypotheses in stack do
4: for all translation options do
5: if applicable then
6: create new hypothesis
7: place in stack
8: recombine with existing hypothesis if possible
9: prune stack if too big
10: end if
11: end for
12: end for
13: end for

我是否错过了任何可以进行基础步骤的方法,或者我应该先编写一些测试用例并完成主要实现?

3个回答

3

简而言之

  1. 将您的任务重新定义为测试API实现。
  2. 由于该算法是用概念(假设翻译选项)表达的,这些概念本身相当复杂,因此通过首先开发(使用TDD)封装这些概念的类来自下而上地编程。
  3. 由于该算法不是特定于语言的,而是抽象了特定于语言的操作(所有翻译选项如果适用),因此将特定于语言的部分封装在单独的对象中,使用依赖注入为该对象,并使用该对象的简单虚拟实现进行测试。
  4. 利用您对算法的了解来建议良好的测试用例。

测试 API

在专注于实现(算法)之前,你会犯一个错误。首先想象一下,如果有一个魔法类可以完成算法的工作,那它的API是什么?它的输入是什么,输出是什么?输入和输出之间必须有哪些必需的连接。您想要将算法封装在此类中,并重新组织问题以生成此类。

在这种情况下,似乎输入是已经进行了分词的句子,输出是已经被翻译成不同语言的分词句子。因此,我猜API大概是这样的:

 interface Translator {
   /**
    * Translate a tokenized sentence from one language to another.
    *
    * @param original
    *    The sentence to translate, split into words,
    *    in the language of the {@linkplain #getTranslatesFrom() locale this
    *    translates from}.
    * @return The translated sentence, split into words,
    *    in the language of the {@linkplain #getTranslatesTo() locale this
    *    translates to}. Not null; containing no null or empty elements.
    *    An empty list indicates that the translator was unable to translate the
    *    given sentence.
    *
    * @throws NullPointerException
    *    If {@code original} is null, or contains a null element.
    * @throws IllegalArgumentException
    *    If {@code original} is empty or has any empty elements.
    */
   public List<String> translate(List<String> original);

   public Locale getTranslatesFrom();

   public Locale getTranslatesTo();
 }

那就是策略设计模式的一个例子。因此,你的任务不再是“如何使用TDD来实现这个算法”,而是“如何使用TDD来实现策略设计模式的一个特定案例”。
接下来,你需要想出一系列测试用例,从最简单到最困难,来使用这个API。也就是说,给translate方法传递一组原始句子值。对于每个输入,你必须给出输出的一组约束条件。翻译必须满足这些约束条件。注意,你已经对输出有一些约束条件:

非空;不包含null或空元素。

你会想要一些例句,你确定算法应该输出什么。我怀疑你会发现这样的句子很少。将这些测试按照从容易通过到难以通过的顺序排列。这将成为你在实现Translator类时的待办事项清单。

自底向上实现

你会发现让你的代码通过超过几个这样的测试用例非常困难。那么你如何彻底测试你的代码呢?

再次查看算法。它足够复杂,以至于translate方法不能直接完成所有工作。它将委托其他类来完成大部分工作。

将空假设放入堆栈0中

您需要一个Hypothesis类吗?一个HypothesisStack类?

对于所有的翻译选项

您需要一个TranslationOption类吗?

如果适用,则

是否有一个TranslationOption.isApplicable(...)方法?

如果可能,与现有假设重新组合

是否有一个Hypothesis.combine(Hypothesis)方法?一个Hypothesis.canCombineWith(Hypothesis)方法?

如果堆栈太大,则修剪堆栈

是否有一个HypothesisStack.prune()方法?

你的实现可能需要额外的类。你可以使用TDD单独实现每个类。你对Translator类的少量测试最终将成为集成测试。其他类将比Translator更容易测试,因为它们将具有明确定义、狭窄定义的任务。

因此,在实现委托类之前,推迟实现Translator。也就是说,我建议你采用自下而上的方式编写代码,而不是自上而下。编写实现给定算法的代码成为最后一步。在那个阶段,你有了可以使用Java代码编写实现的类,这些代码看起来非常像你的算法伪代码。也就是说,你的translate方法的主体只有大约13行。

将真实生活中的复杂性推入相关对象

你的翻译算法是通用的,可以用于任何语言对之间的翻译。我猜想使其适用于特定语言对的原因是“对于所有的翻译选项”和“如果适用,则”部分。我猜想后者可以通过一个“TranslationOption.isApplicable(Hypothesis)”方法来实现。所以使算法特定于某种语言的是生成翻译选项。将其抽象为一个工厂对象,由类委托处理。就像这样:

 interface TranslationOptionGenerator {

    Collection<TranslationOption> getOptionsFor(Hypothesis h, List<String> original);

 }

到目前为止,您可能一直在考虑在真实语言之间进行翻译,其中包含所有令人讨厌的复杂性。但是,您不需要那种复杂性来测试您的算法。您可以使用一对比真实语言简单得多的虚假语言来测试它。或者(等效地)使用一个不像实际应用那么丰富的TranslationOptionGenerator。使用依赖注入TranslatorTranslationOptionGenerator关联起来。

使用简单的虚拟实现代替逼真的关联

现在考虑一些最极端简单的TranslationOptionGenerator案例,算法必须处理:

  • 例如当空假设没有选项时
  • 只有一个选项
  • 只有两个选项。
  • 一种英语到 哈拉帕文的翻译器。没有人知道如何翻译成哈拉帕文,因此这个翻译器每次都指示无法翻译。
  • 身份翻译器:将英语翻译成英语的翻译器。
  • 有限的英语到新生儿翻译器:它只能翻译句子“我饿了”、“我需要换尿布”和“我累了”,它将它们翻译为“Waaaa!”。
  • 简单的英式英语到美式英语翻译器:大多数单词相同,但有少数拼写不同。

您可以使用此功能生成不需要某些循环或不需要进行isApplicable测试的测试用例。

将这些测试用例添加到您的TODO列表中。您将不得不编写虚假的简单TeranslatorOptionGenerator对象,以供这些测试用例使用。


实际上,我可以想出很多带有翻译的例句。问题在于,在其中一句话中,我将不得不进行大跳跃而不是小步骤。我认为将其分成各种类别并不能缓解问题,而且从设计角度来看,我更喜欢算法主体在一个地方以提高可读性。 - oshai
这是一个很好的答案,但它在建议每个小类都需要有自己的一套测试时出现了错误。当然,这样做是完全有效的,但仅为高级客户端类编写测试也是可以的。您始终可以依靠TDD过程中的“重构”步骤来实现良好的OO设计。 - Rogério

1
TL;DR: 从零开始,不要编写任何测试未调用的代码。然后你会进行TDD解决方案。
当通过TDD构建某些东西时,您应该从零开始,然后实现测试用例,直到它执行您想要的操作。这就是为什么他们称之为红绿重构。
您的第一个测试将是查看内部对象是否具有0个假设(空实现将为null [red])。然后初始化假设列表[green]。
接下来,您将编写一个测试以检查假设是否已检查(它刚刚被创建[red])。实现“如果适用”逻辑并将其应用于一个假设[green]。
您编写了一个测试,当一个假设适用时,然后创建一个新的假设(检查是否有> 1个适用于假设的假设[red])。实现创建假设逻辑并将其放入if体中[green]。
相反,如果一个假设不适用,则什么也不做[green]。
只需按照那种逻辑逐个测试算法即可。使用不完整的类比使用完整的类容易得多。

我理解的是你建议从内部到外部进行测试。我理解的对吗? - oshai
是的先生。当你已经完成了实现时,这会变得有点困难。这种方式可以让你的测试描述算法的演化,并且如果你的更改引入了错误,你会立即得到反馈,因为现在你需要回头检查更改的内容以及你的测试是否仍然有效。 - C Bauer
我的回答是一个有效的、高层次的TDD工作方式概述。然而,我认为@Raedwald的回答更具体,描述了TDD如何导致符合SOLID最佳实践的接口。 - C Bauer
1
请注意,TDD 不禁止(或反对)提前 “ 思考 ”。它仅禁止提前 “ 编码 ”。 - cadolphs

1
关键在于要明白,“baby steps”并不一定意味着每次只写很少量的production代码。只要你写了足够多的代码来通过一个相对较小且简单的test,那么你也可以这样做。
有些人认为TDD只能通过编写unit测试来实现,但这并不正确。TDD并不规定你应该编写哪种类型的测试。使用integration测试进行TDD是完全有效的,这些测试可以覆盖大量代码。但每个测试都应该专注于一个明确定义的场景,这个场景才是真正重要的“baby step”,而不是测试可能涉及的类的数量。
个人而言,我使用集成级别的测试开发复杂的Java库,严格遵循TDD流程。经常情况下,我会创建一个非常小而简单的集成测试,最终需要花费很多编程工作才能使其通过,需要更改多个现有类和/或创建新类。这种方法在过去5年多的时间里一直运行良好,我现在拥有超过1300个这样的测试。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接