“一件事情”范例何时会变得有害?


21

为了便于讨论,下面是一个示例函数,该函数逐行打印给定文件的内容。

版本1:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  string line;
  while (std::getline(file, line)) {
    cout << line << endl;
  }
}

我知道建议函数在一个抽象级别上做一件事。对我来说,尽管上面的代码几乎做一件事,而且是原子的。

一些书(例如Robert C. Martin的Clean Code)似乎建议将上述代码分解为单独的函数。

版本2:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  printLines(file);
}

void printLines(fstream & file) {
  string line;
  while (std::getline(file, line)) {
    printLine(line);
  }
}

void printLine(const string & line) {
  cout << line << endl;
}

我了解他们想要实现的目标(打开文件/读取行/打印行),但是这有点过分吗?

原始版本很简单,从某种意义上说已经做了一件事-打印文件。

第二个版本将导致大量非常小的功能,这些功能可能比第一个版本的可读性差很多。

在这种情况下,将代码放在一个地方会更好吗?

在什么时候“一件事”范式变得有害?


13
这种编码实践始终基于个案。从来没有一种方法。
iammilind

1
@Alex-接受的答案与问题无关。我觉得那真的很奇怪。
ChaosPandion 2011年

2
我注意到您的重构版本是颠倒的,这导致缺乏可读性。读取文件后,您会期望看到printFileprintLines最后是printLine
Anthony Pegram

1
@Kev,我再一次只能不同意,尤其是在分类方面。这不是学徒,这就是重点!是OP特别指出第二个版本可能不那么可读。正是OP专门引用了Clean Code作为第二版的灵感。我的评论实质上是,“清洁代码” 不会让他那样写代码。顺序实际上对于提高可读性很重要,就像阅读报纸文章一样阅读文件,获取越来越多的细节,直到您基本上变得不感兴趣为止。
安东尼·佩格拉姆

1
就像您不希望倒着读一首诗一样,您也不希望将最低的细节视为特定类中的第一件事。就您的观点而言,代码花费很少的时间快速进行排序,但是我仅假设此代码不是他要编写的唯一代码。就我而言,如果他要引用“清洁代码”,那么他至少可以做的就是遵循它。如果代码混乱,那么它的可读性肯定会比其他方式低。
安东尼·佩格拉姆

Answers:


15

当然,这只是一个问题,“ 件事是什么?” 读一行是一回事吗?还是将一条线从一条流复制到另一条流被视为一件事?还是复制文件?

没有硬,客观的答案。由你决定。你可以决定。你必须决定。“做一件事”范例的主要目标可能是产生尽可能容易理解的代码,因此您可以将其用作准则。不幸的是,这也不是客观可测量的,因此必须依靠您的直觉和“ WTF?”。计入代码审查

IMO仅由一行代码组成的函数很少值得为此烦恼。printLine()与直接使用std::cout << line << '\n'1相比,您没有优势。如果看到printLine(),我必须假设它按照其名称的含义进行操作,或者查找并检查。如果看到的话std::cout << line << '\n',我立即知道它的作用,因为这是将字符串内容输出为的规范方法std::cout

但是,该范式的另一个重要目标是允许代码重用,这是一种更加客观的措施。例如,在您的第二版中,printLines() 可以很容易地编写它,因此它是一种通用的算法,可以将行从一个流复制到另一个流:

void copyLines(std::istream& is, std::ostream& os)
{
  std::string line;
  while( std::getline(is, line) );
    os << line << '\n';
  }
}

这样的算法也可以在其他上下文中重用。

然后,您可以将特定于此用例的所有内容放到一个调用此通用算法的函数中:

void printFile(const std::string& filePath) {
  std::ifstream file(filePath.c_str());
  printLines(file, std::cout);
}

1 请注意,我使用'\n'而不是std::endl'\n'应该是输出换行符的默认选择,这std::endl是奇怪的情况


2
+1-我大体上同意,但我认为这不只是“胆量感觉”。问题在于人们通过计算实施细节来判断“一件事”。对我来说,该函数应实现(及其名称描述)一个清晰的抽象。您永远不要将函数命名为“ do_x_and_y”。该实现可以并且应该做几件(更简单的)事情-每个更简单的事情都可以分解为几件甚至更简单的事情,依此类推。这只是带有附加规则的功能分解-功能(及其名称)应分别描述一个清晰的概念/任务/任何内容。
Steve314

@ Steve314:我没有列出实现细节作为可能性。从一个流线复制到另一个显然是一件事抽象。还是?do_x_and_y()通过命名该函数很容易避免do_everything()。是的,这是一个愚蠢的例子,但这表明该规则甚至无法防止不良设计的最极端例子。国际海事组织,这一项公约所决定的直觉。否则,如果这是客观的,则可以为此制定一个度量标准,而这是不可能的。
2011年

1
我无意矛盾-只是建议增加一点。我猜我忘了说的是,从问题上讲,分解为printLineetc是有效的-每个分解都是一个抽象-但这并不意味着必须。printFile已经是“一件事”。尽管您可以将其分解为三个单独的较低级别的抽象,但不必在每个可能的抽象级别上进行分解。每个函数都必须执行“一件事”,但并非每个可能的“一件事”都必须是一个函数。将过多的复杂性移入​​调用图本身本身就是一个问题。
Steve314

7

拥有一项功能只能做“一件事情”是达到两个理想目的的手段,而不是上帝的诫命:

  1. 如果您的函数仅做“一件事情”,它将帮助您避免代码重复和API膨胀,因为您将能够编写函数来完成更复杂的事情,而无需对高级,难用的函数进行组合式爆炸。

  2. 仅使函数执行“一件事” 可能会使代码更具可读性。这取决于是否通过将事物去耦而获得更多的清晰度和推理的便利性,而不是失去使事物去耦的构造的冗长,间接和概念上的开销。

因此,“一件事”不可避免地是主观的,并且取决于与程序相关的抽象级别。如果printLines将其视为单一的基本操作,并且是您关心或预见的唯一打印方式,那么出于您的目的,它printLines只会做一件事。除非您发现第二个版本更具可读性(我不认为),否则第一个版本是好的。

如果您开始需要对较低的抽象级别进行更多控制,并以细微的复制和组合爆炸(例如,printLines用于文件名printLinesfstream对象和用于对象的完全分开的对象,printLines用于控制台的对象和printLines用于文件的结尾)结束,那么printLines在该级别上要做的不只是一件事情您关心的抽象。


我要添加第三个,那就是较小的功能更容易测试。如果该功能仅执行一项操作,则可能需要较少的输入,因此可以更轻松地进行独立测试。
PersonalNexus

@PersonalNexus:我在测试问题上有些同意,但是恕我直言,测试实现细节很愚蠢。对我来说,单元测试应该测试答案中定义的“一件事”。任何细粒度的事情都会使您的测试变得脆弱(因为更改实现细节将需要您的测试进行更改)以及您的代码令人讨厌的冗长,间接等(因为您将添加间接性只是为了支持测试)。
dsimcha

6

在这种规模下,没关系。单功能实现非常明显且易于理解。但是,增加一点点复杂性使得将迭代与操作分开非常有吸引力。例如,假设您需要从“ * .txt”之类的模式指定的一组文件中打印行。然后,我将迭代与操作分开:

printLines(FileSet files) {
   files.each({ 
       file -> file.eachLine({ 
           line -> printLine(line); 
       })
   })
}

现在可以单独测试文件迭代。

我拆分功能以简化测试或提高可读性。如果对每行数据执行的操作足够复杂以至于需要发表评论,那么我当然会将其拆分为一个单独的函数。


4
我想你把它钉了。如果我们需要注释来解释一行,那么总是该提取方法了。
Roger CS Wernersson 2011年

5

当您觉得需要注释来解释事情时,请提取方法。

编写仅以其明显方式执行其名称所说的方法,或通过调用巧妙命名的方法来讲述一个故事。


3

即使在简单的情况下,您也缺少“单一责任原则”可以帮助您更好地管理的细节。例如,当打开文件出现问题时会发生什么。添加异常处理以加强对文件访问边缘的处理将为功能添加7-10行代码。

打开文件后,您仍然不安全。它可能会从您那里撤出(特别是如果它是网络上的文件),可能会用完内存,并且可能再次发生许多边缘情况,您想对这些情况进行加固,并且会膨胀您的整体功能。

单线打印线似乎足够无害。但是,随着新功能被添加到文件打印机中(解析和格式化文本,渲染到不同类型的显示器等),它会不断增长,您稍后将感谢您。

SRP的目标是允许您一次考虑一个任务。这就像将一大段文本分成多个段落,以便读者可以理解您试图理解的要点。编写遵循这些原则的代码需要花费更多时间。但是这样做,我们可以更轻松地读取该代码。想一想您将来的自我在他必须跟踪代码中的错误并找到整齐的分区时会感到多么高兴。


2
我赞成这个答案,因为即使我不同意我的逻辑,我也喜欢它!提供基于对将来可能发生的情况的复杂思考的结构会适得其反。在需要时分解代码。在需要之前不要抽象事物。人们试图严格遵循规则,而不仅仅是编写有效的代码并不情愿地适应它,这困扰着现代代码。优秀的程序员是懒惰的
Yttrill 2011年

感谢您的评论。请注意,我并不是在提倡过早的抽象,只是将逻辑运算划分开来,以便以后更容易进行。
迈克尔·布朗

2

我个人更喜欢后一种方法,因为它可以节省您将来的工作,并迫使“如何以通用方式进行操作”的心态。尽管如此,在您的情况下,版本1比版本2更好-仅仅是因为版本2解决的问题过于琐碎且特定于fstream。我认为应该采用以下方式(包括Nawaz提出的错误修复):

通用工具功能:

void printLine(ostream& output, const string & line) { 
    output << line << endl; 
} 

void printLines(istream& input, ostream& output) { 
    string line; 
    while (getline(input, line)) {
        printLine(output, line); 
    } 
} 

特定于域的功能:

void printFile(const string & filePath, ostream& output = std::cout) { 
    fstream file(filePath, ios::in); 
    printLines(file, output); 
} 

现在printLines并且printLine不仅可以与一起使用fstream,而且可以与任何流一起使用。


2
我不同意。该printLine()功能没有价值。看我的回答
2011年

1
好吧,如果我们保留printLine(),那么我们可以添加一个装饰器,该装饰器添加行号或语法着色。话虽如此,在找到理由之前,我不会提取这些方法。
Roger CS Wernersson 2011年

2

遵循的每个范式(并不一定是您引用的范式)都需要一定的纪律,因此-减少“言论自由”-会产生初始开销(至少是因为您必须学习它)。从这个意义上说,当开销的成本并未因该范式旨在保持自身优势而得到过度补偿时,每种范式都会变得有害。

因此,要想真正解决该问题,就需要具备“预见”未来的良好能力,例如:

  • 现在需要做的AB
  • 什么是概率,在不久的将来,我会还要求做A-B+(即东西,看起来像A和B,但只是有点不同)?
  • 在更远的将来,A +会变成A*或的可能性是A*-多少?

如果该可能性相对较高,那么在考虑A和B时,我也会考虑它们的可能变体,从而隔离出通用部分,以便我可以重用它们,这将是一个很好的机会。

如果该概率非常低(周围的任何变化A本质上仅是A自身),请研究如何进一步分解A,很可能会浪费时间。

仅作为示例,让我告诉您这个真实的故事:

在我过去的老师生涯中,我发现-在大多数学生的项目中-实际上所有项目都提供了自己的函数来计算C字符串的长度

经过一番调查,我发现,作为一个常见问题,所有学生都想到了为此使用函数的想法。在告诉他们该函数有一个库函数之后strlen,许多人回答说,由于问题是如此简单和琐碎,因此,他们编写自己的函数(两行代码)比寻求C库手册更有效。 (那是1984年,忘记了WEB和google!),按严格的字母顺序查看是否有相应的就绪功能。

这是一个示例,在没有有效的车轮目录的情况下,“不要重新发明车轮”范式也会变得有害!


2

您的示例很好用在昨天用来完成某些特定任务的一次性工具中。或作为由管理员直接控制的管理工具。现在使其健壮起来,使其适合您的客户。

通过有意义的消息添加适当的错误/异常处理。也许您需要参数验证,包括必须做出的决定,例如,如何处理不存在的文件。添加日志记录功能,可能具有不同级别,例如信息和调试。添加评论,以便您的团队同事知道发生了什么。添加为简洁起见通常省略的所有部分,并在给出代码示例时作为练习供读者阅读。不要忘记您的单元测试。

您的漂亮且线性的小函数突然以复杂的混乱结束,乞求将其拆分为单独的函数。


2

IMO远远超出了一个功能,除了将工作委托给另一个功能以外几乎什么都不做,这是有害的,因为这表明它不再是任何东西的抽象,导致此类功能的思维定势始终处于危险之中。做得更糟...

来自原始帖子

void printLine(const string & line) {
  cout << line << endl;
}

如果您有足够的知识,您可能会注意到printLine仍然做两件事:将行写入cout并添加“结束行”字符。某些人可能想通过创建新功能来解决此问题:

void printLine(const string & line) {
  reallyPrintLine(line);
  addEndLine();
}

void reallyPrintLine(const string & line) {
  cout << line;
}

void addEndLine() {
  cout << endl;
}

哦,不,现在我们使问题更加严重了!现在甚至很明显printLine可以做两件事!!! 1!创建一个可以想象的最荒谬的“变通办法”并没有多大愚蠢,只是摆脱了不可避免的问题,即打印行包括打印行本身并添加行尾字符。

void printLine(const string & line) {
  for (int i=0; i<2; i++)
    reallyPrintLine(line, i);
}

void reallyPrintLine(const string & line, int action) {
  cout << (action==0?line:endl);
}

1

简短的答案...这取决于。

考虑一下:如果将来您不想仅打印到标准输出,而是打印到文件,该怎么办?

我知道YAGNI是什么,但是我只是说可能在某些情况下需要某些实现,但被推迟了。因此,也许架构师或其他知道该功能的人也需要能够将其打印到文件中,但现在不想执行该实现。因此,他创建了这个额外的功能,因此,将来您只需要在一个位置更改输出即可。说得通?

但是,如果您确定只需要在控制台中输出,那实际上就没有多大意义。写一个“包装” cout <<似乎没用。


1
但是严格来说,printLine函数和遍历行是否是不同级别的抽象?

@Petr我想是的,这就是为什么他们建议您将功能分开。我认为这个概念是正确的,但是您需要根据具体情况加以应用。

1

有一整本书专门讲“做一件事”的优点的全部原因是,那里仍然有开发人员编写长度为4页的函数并嵌套6个级别的条件。如果您的代码简单明了,那么您做对了。


0

正如其他张贴者所评论的那样,做一件事情只是规模问题。

我还建议“一件事”的想法是阻止人们通过副作用进行编码。这可以通过顺序耦合来说明,其中必须以特定的顺序调用方法以获得“正确的”结果。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.