太多的抽象使得代码难以扩展


9

我在代码库中感觉到太多抽象(或者至少是处理它)时遇到了问题。代码库中的大多数方法已被抽象为采用代码库中最高的父级A,但是此父级的子级B具有新属性,该属性会影响其中某些方法的逻辑。问题在于这些属性无法在那些方法中检查,因为输入被抽象为A,而A当然没有此属性。如果我尝试创建一个新的方法来不同地处理B,则会被调用以进行代码复制。我的技术负责人的建议是创建一个使用布尔参数的共享方法,但是这样做的问题是,有些人将其视为“隐藏的控制流”,其中共享方法具有的逻辑对于将来的开发人员可能并不明显。 ,并且即使需要添加将来的属性,即使将其分解为较小的共享方法,该共享方法也会变得过于复杂/复杂。这也增加了耦合,降低了凝聚力,并且违反了我团队中有人指出的单一责任原则。

本质上,此代码库中的许多抽象有助于减少代码重复,但是当使它们采用最高抽象时,这会使扩展/更改方法变得更加困难。在这种情况下我该怎么办?尽管其他人不能就他们认为的好事达成共识,但我还是要怪罪于我,这最终伤害了我。


10
添加代码样本来
简化

我认为这里有两个SOLID原则。单一职责-如果将布尔值传递给应该控制行为的函数,则该函数将不再具有单一职责。另一个是利斯科夫换人原则。假设有一个函数将类A作为参数。如果您传递的是B类而不是A类,那么该功能的功能是否会被破坏?
bobek

我怀疑方法A相当长,并且不只做一件事。是这样吗
Rad80

Answers:


27

如果我尝试创建一个新的方法来不同地处理B,则会被调用以进行代码复制。

并非所有的代码重复创建都相同。

假设您有一个采用两个参数并将它们加在一起的方法,称为total()。假设您还有一个叫add()。它们的实现看起来完全相同。是否应将它们合并为一种方法?没有!!!

鸵鸟政策重复自己动手DRY原则是不是重复的代码。这是关于传播决策,想法的地方,因此,如果您改变想法,则必须重写所有传播想法的地方。布莱 这太可怕了。不要这样 而是使用DRY帮助您在一处做出决策

DRY(请勿重复)原则规定:

每条知识都必须在系统中具有单一,明确,权威的表示形式。

wiki.c2.com-不要重复自己

但是DRY可能会变成一种习惯,即扫描代码以寻找类似的实现,似乎是在其他地方进行复制和粘贴。这是DRY的脑残形式。地狱,您可以使用静态分析工具来完成此操作。这没有帮助,因为它忽略了DRY 的要点,即保持代码的灵活性。

如果我的总需求发生变化,则可能不得不更改我的total实现。这并不意味着我需要更改add实现。如果有些菜鸟将它们一起捣成一种方法,那么我现在会感到不必要的痛苦。

多少痛苦?当然,我可以只复制代码并在需要时制作一个新方法。所以没什么大不了的吗?说大话!如果没有别的,你花了我一个好名字!好名字很难得到,并且当您摆弄好名字的含义时也不会很好地回应。清楚表明意图的好名字比复制一个错误的风险更为重要,坦白说,如果您的方法具有正确的名称,则该错误更容易修复。

因此,我的建议是不要让对类似代码的下意识的反应束缚您的代码库。我并不是说您可以随意忽略方法存在的事实,而是复制并粘贴willy nilly。不,每种方法都应该有一个该死的好名字,以支持它所涉及的一个想法。如果其实现恰好与其他好主意的实现相匹配,那么现在,今天,到底谁在乎?

另一方面,如果您的sum()方法与相比具有相同甚至不同的实现total(),但是每次更改总需求时,您都必须进行更改,sum()那么很有可能在两个不同的名称下这是相同的想法。如果将代码合并在一起,它们不仅会更灵活,而且使用起来也不会那么混乱。

至于布尔参数,是的,这是一个讨厌的代码味道。控制流不仅是一个问题,更糟糕的是,这表明您在一个糟糕的地方切入了抽象。抽象应该使事情更简单易用,而不是更复杂。将布尔值传递给方法来控制其行为就像创建一种秘密语言来决定您真正调用的方法一样。!!不要对我那样 除非您对诚实的多态性正在进行中的工作有所了解,否则请给每个方法指定自己的名称。

现在,您似乎对抽象感到精疲力尽。太糟糕了,因为如果做得好,抽象是一件很棒的事情。您经常使用它而无需考虑它。每次开车时都不必了解齿条和小齿轮系统,每次使用打印命令时都无需考虑操作系统中断,每次刷牙时都无需考虑每个单独的刷毛。

不,您似乎要面对的问题是糟糕的抽象。创建抽象的目的是为了满足您的需求。您需要将简单的接口连接到复杂的对象中,这些接口使您无需了解那些对象就可以满足您的需求。

当编写使用另一个对象的客户端代码时,您会知道自己的需求以及该对象的需求。没有。这就是客户端代码拥有接口的原因。当您是客户时,除了您之外,您什么都无法告诉您您的需求。您将显示一个界面,该界面显示您的需求,并要求交付给您的一切东西都可以满足这些需求。

那就是抽象。作为客户,我什至不知道我在说什么。我只是知道我需要什么。如果那意味着您必须包装一些东西以更改其界面,然后再交给我。我不在乎 做我需要做的事。别再复杂了。

如果必须查看抽象以了解如何使用它,那么该抽象失败了。我不需要知道它是如何工作的。只是它有效。给它起个好名字,如果我向内看,我对我的发现也不会感到惊讶。不要让我一直向内看以记住如何使用它。

当您坚持认为抽象以这种方式工作时,其背后的级别数无关紧要。只要您不在抽象的背后。您坚持认为抽象符合您的需求而不适应它。为了使它起作用,它必须易于使用,有一个好名声并且不泄漏

这就是产生依赖注入的态度(或者,如果您像我这样的老派,只是引用通过)。与继承相比,它与首选的组合和授权效果很好。态度有很多名字。我最喜欢的是告诉,不要问

我可以整日淹死你。听起来您的同事已经在。但事实是:与其他工程领域不同,该软件的历史还不到100年。我们都还在努力。因此,不要让拥有许多令人生畏的发音书籍的人欺负您,让他们编写难以阅读的代码。听他们的话,但坚持说他们有道理。不要相信任何东西。仅仅因为被告知这种方式而以某种方式编码的人,却不知道为什么造成最大的混乱。


我完全同意。DRY是三个单词的标语的三个字母的缩写,不要重复自己,这又是Wiki 14页文章。如果您只是盲目地抱怨这三个字母,而没有阅读和理解这14页的文章,那么您遇到麻烦。它也与“只有一次”(OAOO)密切相关,与“ 单一真相”(SPOT)/“单一真相来源”(SSOT)更为松散。
约尔格W¯¯米塔格

“它们的实现看起来完全相同。应该将它们合并为一种方法吗?不!!!” –反之亦然:两段代码不同并不意味着它们不是重复的。罗恩·杰弗里斯(Ron Jeffries)在OAOO维基页面上有一个引人注目的引述:“我曾经看到贝克宣布将几乎完全不同的代码的两个补丁声明为“重复”,将它们更改为可以重复的,然后删除新插入的重复出现有明显更好的东西。”
约尔格W¯¯米塔格

@JörgWMittag当然。最重要的是想法。如果您用不同的外观代码来复制该想法,那么您仍然会犯规。
candied_orange

我必须想象一篇14页的文章,关于不重复自己会重复很多次。
查克·亚当斯

7

我们到处都读的通常的说法是:

可以通过添加另一层抽象来解决所有问题。

好吧,这不是真的!您的示例显示了这一点。因此,我建议对语句进行一些修改(可以随时重用;-)):

使用正确的抽象级别可以解决每个问题。

您的情况有两个不同的问题:

  • 过度概括因添加在抽象的层次上的每个方法;
  • 具体行为的零散导致人们没有得到大印象和迷失方向的印象。有点像在Windows事件循环中。

两者是相关的:

  • 如果您抽象出每个专业化方法都不同的方法,那么一切都很好。掌握a Shape可以surface()以专门的方式计算它的人没有问题。
  • 如果您对具有常见常规行为模式的某些操作进行抽象,则有两种选择:

    • 或者您将在每个专业领域中重复常见的行为:这是非常多余的;且难以维护,尤其是要确保各个专业领域的共同点保持一致:
    • 您可以使用模板方法模式的某种变体 :这使您可以通过使用可以轻松地实现专门化的其他抽象方法来考虑常见行为。它没有那么多冗余,但是其他行为往往变得极为分裂。太多意味着它可能太抽象了。

此外,这种方法可能会在设计级别上产生抽象的耦合效果。每次您想要添加某种新的特殊行为时,都必须对其进行抽象,更改抽象父级并更新所有其他类。那不是人们可能想要的那种变化传播。而且它并不是真的不依赖于专业化(至少在设计中),不属于抽象精神。

我不知道您的设计,无法提供更多帮助。也许这确实是一个非常复杂和抽象的问题,没有更好的方法。但是几率是多少?过度概括的症状在这里。也许是时候再次考虑 它,并考虑合成而不是泛化了吗?


5

每当我看到一个行为在其参数类型上切换的方法时,我会立即首先考虑该方法是否实际上属于方法参数。例如,没有像这样的方法:

public void sort(List values) {
    if (values instanceof LinkedList) {
        // do efficient linked list sort
    } else { // ArrayList
        // do efficient array list sort
    }
}

我会这样做:

values.sort();

// ...

class ArrayList {
    public void sort() {
        // do efficient array list sort
    }
}

class LinkedList {
    public void sort() {
        // do efficient linked list sort
    }
}

我们将行为转移到知道何时使用它的地方。我们创建了一个真正的抽象,您无需了解实现的类型或细节。对于您的情况,将此方法从原始类(我将称为O)移动到type A并在type中覆盖它可能更有意义B。如果doIt在某个对象上调用了该方法,请移动doItA并覆盖在不同的行为B。如果doIt最初调用的位置有数据位,或者在足够的地方使用了该方法,则可以保留原始方法并委托:

class O {
    int x;
    int y;

    public void doIt(A a) {
        a.doIt(this.x, this.y);
    }
}

不过,我们可以进一步深入。让我们看一下使用布尔参数代替的建议,看看我们可以从中了解到您的同事的思维方式。他的建议是:

public void doIt(A a, boolean isTypeB) {
    if (isTypeB) {
        // do B stuff
    } else { 
        // do A stuff
    }
}

看起来很像 instanceof我在第一个示例中使用的,除了我们正在外部化该检查。这意味着我们将不得不通过以下两种方式之一来调用它:

o.doIt(a, a instanceof B);

要么:

o.doIt(a, true); //or false

在第一种方式中,呼叫点不知道A它的类型。因此,我们是否应该将布尔值一直传递下去?那真的是我们希望在整个代码库中使用的模式吗?如果我们需要考虑第三种情况会怎样?如果这是方法的调用方式,则应将其移至类型,然后让系统多态地为我们选择实现。

第二种方式,我们必须已经知道a呼叫点的类型。通常这意味着我们要么在那儿创建实例,要么将这种类型的实例作为参数。在此处创建一个方法O需要一个B。编译器会知道选择哪种方法。当我们在经历这样的变化时,,至少弄清楚抽象要好重复至少要弄清楚我们要去的方向。当然,我建议无论我们对此进行了什么更改,我们都不会真正完成。

我们需要更仔细地研究A和之间的关系B。通常,我们被告知,我们应该更倾向于继承而不是继承。并非在每种情况下都是如此,但是一旦我们B从中挖掘继承自A,这意味着在令人惊讶的多种情况下都是如此,这意味着我们认为BAB应该像一样使用A,除了它的工作方式略有不同。但是这些区别是什么?我们可以给差异起一个更具体的名字吗?这难道不B就是一个A,但真正AX可能是A'B'?如果这样做,我们的代码将是什么样?

如果我们移动的方法到A如前面所说,我们可以注入的情况下X进入A,并委托该方法的X

class A {
    X x;
    A(X x) {
        this.x = x;
    }

    public void doIt(int x, int y) {
        x.doIt(x, y);
    }
}

我们可以实现A'B'并摆脱它B。我们通过给可能更隐式的概念命名来改进了代码,并允许我们自己在运行时而不是编译时设置该行为。A实际上也变得不太抽象。它不是在扩展的继承关系上,而是在委托的对象上调用方法。该对象是抽象的,但仅专注于实现上的差异。

不过,还有最后一件事要看。让我们回到您同事的建议。如果在所有呼叫站点上我们都明确知道A我们拥有的类型,那么我们应该像这样进行呼叫:

B b = new B();
o.doIt(b, true);

我们在撰写时假设Aa的X值为A'or或B'。但是也许这个假设是不正确的。这是A和之间唯一重要的地方B吗?如果是这样,那么也许我们可以采取稍微不同的方法。我们还有一个X要么是A'B',但它不属于A。只O.doIt关心它,所以我们只将它传递给O.doIt

class O {
    int x;
    int y;

    public void doIt(A a, X x) {
        x.doIt(a, x, y);
    }
}

现在我们的呼叫站点看起来像:

A a = new A();
o.doIt(a, new B'());

再次B消失,抽象移到更集中的位置X。但是,这一次A通过了解更少而变得更加简单。它甚至不那么抽象。

减少代码库中的重复很重要,但是我们必须考虑为什么重复首先发生。复制可能是试图摆脱困境的更深层抽象的标志。


1
令我惊讶的是,您在此处提供的示例“错误”代码类似于我倾向于使用非OO语言编写的代码。我想知道他们是否吸取了错误的教训,并以他们的编码方式将他们带入面向对象的世界?
Baldrickk

1
@Baldrickk每个范式都有其自己的思维方式,各有其优缺点。在功能强大的Haskell中,模式匹配将是更好的方法。尽管使用这样的语言,原始问题的某些方面也不可能。
cbojar

1
这是正确的答案。根据操作类型更改实现的方法应该是该类型的方法。
罗曼·赖纳

0

通过继承进行抽象可能会变得很丑陋。具有典型工厂的并行类层次结构。重构可能成为头痛。以及以后的开发,即您所处的位置。

还有一种选择:扩展点,严格的抽象和分层的自定义。根据针对特定城市的自定义项,对政府客户进行一次自定义项。

警告:不幸的是,当对所有(或大多数)类进行扩展时,这种方法最有效。没有选择的余地,也许很小。

这种可扩展性通过使可扩展对象基类拥有扩展来起作用:

void f(CreditorBO creditor) {
    creditor.as(AllowedCreditorBO.class).ifPresent(allowedCreditor -> ...);
}

在内部,扩展类将对象延迟映射到扩展对象。

对于GUI类和组件,具有相同的可扩展性,部分具有继承性。添加按钮等。

在您的情况下,验证应查看其是否扩展,并针对扩展进行自我验证。仅在一种情况下引入扩展点会增加难以理解的代码,不好。

因此,除了尝试在当前上下文中工作之外,没有其他解决方案。


0

“隐藏的流量控制”对我来说听起来像是手工的。
脱离上下文的任何构造或元素都可能具有该特征。

抽象是好的。我用两个准则来调整它们:

  • 最好不要过早抽象。等待更多模式示例,然后再进行抽象。“更多”当然是主观的,并且是针对困难情况的。

  • 仅仅因为抽象是好的就避免过多的抽象级别。程序员在探究代码库并深入12个级别时,必须将这些级别保持在头脑中,以获取新的或更改的代码。对精练的代码的渴望可能导致如此之高的水平,以至于许多人难以遵循。这也会导致“仅保留忍者”代码库。

在两种情况下,“更多”和“太多”都不是固定数字。这取决于。那就是很难。

我也喜欢Sandi Metz的这篇文章

https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction

复制比错误的抽象要便宜得多,
并且比错误的抽象
更喜欢复制

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.