如何将代码分成组件...大类?小类?

17

这是非常基础的内容,但我想说一下。我发现我从来不能确定我把大类分成较小类的方法是否会使事情更容易维护还是更难以维护。我对设计模式有所了解,但不是很深入,并且也熟悉面向对象设计的概念。除了所有花哨的规则和指南,我正在寻求您的建议,针对一个非常简单的场景,希望您能告诉我什么是我所不知道且缺少经验的东西,例如:" ...由于某种设计,这将使它更加困难" 等等。

假设您需要编写一个基本的“文件阅读器/文件写入器”类来处理特定类型的文件。让我们把这个文件称为YadaKungFoo文件。YadaKungFoo文件的内容与INI文件基本相似,但存在细微差异。

它们都有各自的部分和值:

[Sections]
Kung,Foo
Panda, Kongo

[AnotherSection]
Yada,Yada,Yada
Subtle,Difference,Here,Yes

[Dependencies]
PreProcess,SomeStuffToPreDo
PreProcess,MoreStuff
PostProcess,AfterEight
PostProcess,TheEndIsNear
PostProcess,TheEnd

好的,这可以产生3个基本类:

public class YadaKungFooFile
public class YadaKungFooFileSection
public class YadaKungFooFileSectionValue

后两个类本质上只是带有 ToString() 覆盖的数据结构,用一些泛型列表存储值的字符串列表。这足以实现 YadaKungFoo 文件保存功能。

随着时间推移,YadaYadaFile 开始增长。包括 XML 等多种格式的几个重载版本,并且文件开始向 800 行左右发展。 现在真正的问题:我们想要添加一个功能来验证 YadaKungFoo 文件的内容。首先想到的当然是添加:

var yada = new YadaKungFooFile("C:\Path");
var res = yada .Validate()

我们完成了(我们甚至可以从构造函数调用该方法)。问题是验证过程相当复杂,使得类变得非常庞大,因此我们决定创建一个新类,如下所示:

var yada = new YadaKungFooFile("C:\Path"); 
var validator = new YadaKungFooFileValidator(yada); 
var result = validator.Validate();

很明显,这个例子非常简单、琐碎而且微不足道。上述两种方式都可能没有太大的区别,但我不喜欢的是:

  1. 按照这种设计,YadaKungFooFileValidator类和YadaKungFooFile类似乎耦合度非常高。一个类的更改可能会触发另一个类的变化。
  2. 我知道,“Validator”、“Controller”、“Manager”等短语表示的类关注其他对象的状态,而不是自己的“业务”,因此违反了关注点分离原则和消息发送原则。

总之,我认为我没有足够的经验来理解一个设计为什么不好、在什么情况下它并不重要以及哪个问题更加重要:小型类还是更具内聚性的类。它们似乎是相互矛盾的要求,但也许我错了。也许验证器类应该是一个组合对象?

实质上,我正在寻求对上述设计可能产生的利弊的评论。有什么可以做得不同吗?(基于FileValidator类、FileValidator接口等)。考虑到YadaKungFooFile功能随着时间的推移而不断增长。

7个回答

15

Bob Martin撰写了一系列关于类设计的文章,他将这些原则称为SOLID原则:

http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod

这些原则是:

  1. 单一责任原则:一个类应该只有一个变化的理由。
  2. 开闭原则:你应该能够扩展一个类的行为,而不修改它。
  3. 里氏替换原则:派生类必须能够替换其基类。
  4. 接口隔离原则:创建细粒度的特定于客户端的接口。
  5. 依赖倒置原则:依赖于抽象而不是具体实现。

因此,在这些原则的指导下,让我们来看一些语句:

所以随着时间的推移,YadaYadaFile开始增长。几个重载功能保存在不同的格式中,包括XML等,文件开始向800行左右推进

这是一个第一道警示:YadaYadaFile最初有两个职责:1)维护部分/键/值数据结构,和2)知道如何读写类似INI的文件。所以第一个问题就出现了。添加更多的文件格式会加剧这个问题:当1)数据结构改变时,或2)文件格式改变时,或3)新的文件格式被添加时,YadaYadaFile就会发生变化。

同样,对于所有这三个职责都有一个单一验证器会给该单一类带来过多压力。

大型类是"代码异味":它本身并不是坏的,但它往往是真正糟糕的东西的结果——在这种情况下,是一个试图做太多事情的类。


14

我认为类的大小并不是一个问题。关键是要考虑内聚性和耦合性。你需要设计松散耦合和内聚性强的对象,也就是说,它们应该只关注一个明确定义的事物。因此,如果这个事物非常复杂,那么这个类的大小就会增长。

你可以使用各种设计模式来帮助管理复杂性。例如,你可以创建一个验证器接口,并让YadaKunfuFile类依赖于这个接口而不是验证器。这样,只要接口不改变,你就可以更改验证器类而不必更改YadaKungfuFile类。


4
“我不认为班级的规模是一个问题。”从理论上讲,我倾向于同意。但实践中,我从未见过一门超过2000行代码的课程能让我满意。 - PeterAllenWebb
1
@PeterAllenWebb ... 我同意,但代码行数不应该是决定因素,而是类的语义应该帮助确定类是否涉及一个事物还是多个事物。实际上,大多数类都会很小。 - Vincent Ramdhanie
1
@Vincent Ramdhanie LOC 是代码质量的一个很好的指标,我的一般规则是如果一个方法中有超过100行代码,就应该怀疑它是否合理,直到证明它是无辜的;对于类来说,500行代码也是如此。 - Bob The Janitor
代码行数不应该是决定性因素,但是大量代码行绝对是糟糕代码的一种征兆。一个函数应该只有一个目的,如果一个函数的目的可以被分割,那么它就应该被拆分成多个函数。同样的规则也适用于类。在大多数情况下,我发现巨大的类包含了很多不属于它们的内容。例如,我曾经看到一个数据库查询对象,其中包含了日期函数、定价函数甚至总账会计函数。虽然这可能是个例外情况,但是做一些非常重要的事情的巨大类很少会非常专注。 - Cobus Kruger

5
YadaKungFooFile不应该知道如何从磁盘上读取自身。它只应该知道如何遍历自己,公开其子项等。它还应该提供添加/删除子项等方法。
应该有一个IYadaKungFooReader接口,YadaKungFooFile在其Load方法中会使用该接口来从磁盘加载自身。此外,还应该有多个实现,例如AbstractKungFooReader、PlainTextYadaKungFooReader、XMLYadaKungFooWriter,它们知道如何读取相应的格式。
写入时也是同样的情况。
最后,应该有一个YadaKungFooValidatingReader,它将接收一个IYadaKungFooReader阅读器,并在阅读时验证输入。然后,当您希望从磁盘读取并验证时,将验证阅读器传递给YadaKungFooFile.Load。
或者,您可以使阅读器成为主动类,而不是被动类。然后,您将使用它作为工厂来创建您的YadaKungFooFile,使用后者的正常访问方法。在这种情况下,您的阅读器还应该实现YadaKungFooFile接口,以便您可以链接正常阅读器->验证阅读器->YadaKungFooFile。明白了吗?

2

我来解决这个问题。

根据这个设计,YadaKungFooFileValidator类和YadaKungFooFile类似乎紧密耦合在一起。一个类的更改可能会触发另一个类的更改。

是的,因此为了尽可能地使其无缝,需要想办法。最好的情况是,设计YadaKungFooFile类时,验证器可以自行对其进行检查。

首先,语法本身应该很容易。我不希望语法验证发生变化,因此您可以安全地将其硬编码。

至于可接受的属性,您可以从File类中公开枚举器,Validator将对它们进行检查。如果给定部分的解析值不在任何枚举器中,则抛出异常。

以此类推...


0

在这种情况下,我个人处理事情的方式是创建一个YadaKungFooFile类,它由YadaKungFooFileSections列表组成,而每个YadaKungFooFileSection又由YadaKungFooFileValues列表组成。

对于验证器,您需要让每个类调用其下面类的validate方法,而层次结构中最低的类实现验证方法的实际内容。


0

如果验证不依赖于整个文件,而是仅在单个部分值上工作,则可以通过指定一组操作部分值的验证器来分解YadaKungFooFile类的验证。

如果验证确实依赖于整个文件,则验证自然与文件类耦合,您应该考虑将其放入一个类中。


0

类的大小并不是问题,方法的大小才是。类是一种组织方式,虽然有好坏之分,但它与类的大小没有直接关系。

方法的大小则不同,因为方法是实际工作的执行者。方法应该有非常具体的任务,这必然会限制它们的大小。

我发现确定方法大小的最佳方法是编写单元测试。如果您编写了具有足够代码覆盖率的单元测试,则当一个方法过大时就会变得明显。

单元测试方法需要简单,否则您最终将需要测试来测试您的单元测试以确保它们正常工作。如果您的单元测试方法变得复杂,那很可能是因为某个方法试图做太多事情,应该将其拆分为具有明确职责的较小方法。


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