如何在C中应用接口隔离原则?


15

我有一个模块,例如“ M”,其中有一些客户端,例如“ C1”,“ C2”,“ C3”。我想将模块M的名称空间(即,其公开的API和数据的声明)分配给头文件,使得-

  1. 对于任何客户端,仅可见其所需的数据和API;模块的其余名称空间对客户端是隐藏的,即遵守接口隔离原则
  2. 在多个头文件中不重复声明,即不违反DRY
  3. 模块M与其客户端没有任何依赖关系。
  4. 客户端不受模块M未被其使用的部分中所做的更改的影响。
  5. 现有客户端不受添加或删除更多客户端的影响。

目前,我通过根据模块客户端的需求划分模块的命名空间来解决此问题。例如,在下面的图像中,显示了其3个客户端所需的模块名称空间的不同部分。客户要求有重叠。模块的名称空间分为4个单独的头文件-'a','1','2'和'3'

模块名称空间分区

但是,这违反了上述某些要求,即R3和R5。违反了要求3,因为这种划分取决于客户端的性质;在添加新客户端时,该分区也会更改并违反要求5。如上图右侧所示,在添加新客户端后,模块的命名空间现在分为7个头文件-'a ”,“ b”,“ c”,“ 1”,“ 2 *”,“ 3 *”和“ 4”。用于2个旧客户端的头文件发生更改,从而触发了其重建。

有没有办法以非人为方式在C中实现接口隔离?
如果是,您将如何处理上述示例?

我想象的是一个虚幻的假设解决方案-
该模块有1个胖头文件覆盖了整个名称空间。该头文件分为可寻址的部分和子部分,如Wikipedia页面。然后,每个客户端都有一个为其量身定制的特定头文件。特定于客户机的头文件只是指向胖头文件的节/子节的超链接的列表。如果构建模块指向模块头文件中的任何部分,则构建系统必须将特定于客户端的头文件识别为“已修改”。


1
为什么此问题特定于C?是因为C没有继承吗?
罗伯特·哈维

此外,违反ISP是否会使您的设计工作更好?
罗伯特·哈维

2
C实际上并没有从本质上支持OOP概念(例如接口或继承)。我们通过粗暴的(但富有创造力的)骇客解决了问题。寻找一种模拟接口的技巧。通常,整个头文件是模块的接口。
work.bin

1
struct是需要接口时在C中使用的功能。当然,方法有点困难。您可能会发现这很有趣:cs.rit.edu/~ats/books/ooc.pdf
Robert Harvey

我无法提供使用struct和的等效接口function pointers
work.bin

Answers:


5

通常,接口隔离不应基于客户端要求。您应该更改整个方法以实现它。我要说的是,通过将功能分组到相关的组中来对接口进行模块化。也就是说,分组是基于功能本身的一致性,而不是基于客户需求。在这种情况下,您将拥有一组接口I1,I2等。客户端C1可以单独使用I2。客户端C2可以使用I1和I5等。请注意,如果客户端使用多个Ii,则不会有问题。如果您已将接口分解为一致的模块,那么这就是问题的核心所在。

同样,ISP不是基于客户端的。它是关于将接口分解成较小的模块。如果正确完成此操作,还将确保客户端可以使用所需数量的功能。

通过这种方法,您的客户可以增加到任意数量,但是您M不受影响。每个客户端将根据其需求使用一种或某种接口组合。在某些情况下,客户端C需要包含I1和I3,但不使用这些接口的所有功能吗?是的,那不是问题。它仅使用最少数量的接口。


我认为您肯定是不相交不重叠的组。
布朗

是的,不相交且不重叠。
纳扎·梅尔扎

3

接口分离原则说:

不应强迫任何客户端依赖其不使用的方法。ISP将非常大的接口拆分为更小和更具体的接口,以便客户端仅需了解他们感兴趣的方法。

这里有一些未解决的问题。一种是:

多么小?

你说:

目前,我通过根据模块客户端的需求划分模块的命名空间来解决此问题。

我称这种手动鸭子打字。您构建的界面仅显示客户端需要的内容。接口隔离的原理不仅仅是简单的鸭子输入。

但是,ISP也不只是简单地呼吁可以重用的“一致”角色接口。没有“连贯的”角色界面设计可以完美地防止添加具有其自身角色需求的新客户端。

ISP是一种使客户端免受服务更改影响的方法。它的目的是使更改时构建速度更快。当然,它还有其他好处,例如不破坏客户,但这是重点。如果我要更改服务count()功能签名,那么不用的客户端count()不需要编辑和重新编译就很好了。

这就是为什么我关心接口隔离原则。我认为信念并不重要。它解决了一个真正的问题。

因此,应采用的方式应该可以为您解决问题。应用ISP并没有死胡同的死法,仅通过正确的必要变更示例就可以击败它。您应该查看系统的变化方式,并做出选择以使事情平静下来。让我们探索选项。

首先问自己:现在很难对服务接口进行更改吗?如果没有,请出去玩直到你冷静下来。这不是理智的练习。请确保治愈不会比疾病差。

  1. 如果许多客户端使用相同的功能子集,则说明“连贯”的可重用接口。该子集可能围绕一种想法,我们可以将其视为服务提供给客户的角色。这很有效。这并不总是有效。

  2.  

    1. 如果许多客户端使用不同的功能子集,则该客户端实际上可能通过多个角色使用该服务。可以,但是很难看到角色。找到它们并尝试将它们分开。这可能使我们回到第一种情况。客户端仅通过多个接口使用该服务。请不要开始投放服务。如果这意味着不止一次将服务传递给客户端。那行得通,但是让我怀疑服务是否不是一个需要解决的大难题。

    2. 如果许多客户端使用不同的子集,但您甚至看不到角色,甚至不允许客户端使用多个子集,那么您没有比鸭子输入更好的设计接口的了。这种设计界面的方式可确保即使客户端未使用的功能也不会暴露给客户端,但几乎可以确保添加新客户端将始终涉及添加新接口,而服务实现不需要知道关于它,聚合角色接口的接口将。我们只是用一种痛苦换了另一种痛苦。

  3. 如果许多客户端使用不同的子集(重叠),则需要添加新的客户端,这些子集将需要不可预测的子集,并且您不愿意拆分服务,然后考虑使用功能更强大的解决方案。由于前两个选项不起作用,并且您实际上处在一个糟糕的地方,没有任何模式可循,并且即将进行更多更改,因此请考虑为每个功能提供自己的接口。结束此处并不意味着ISP已失败。如果失败了,那就是面向对象的范例。单一方法接口在极端情况下遵循ISP。这是相当多的键盘输入,但是您可能会发现这突然使接口可重用。同样,请确保没有

事实证明,它们确实可以变得很小。

我已将此问题视为在最极端的情况下应用ISP的挑战。但是请记住,最好避免极端情况。在经过深思熟虑的应用其他SOLID原理的设计中,这些问题通常几乎不会发生或没有关系。


另一个未解决的问题是:

谁拥有这些接口?

我一遍又一遍地看到以我所谓的“图书馆”心态设计的界面。我们都对猴子看到猴子做编码感到内gui,因为您只是在做某事,因为那是您看到的方式。我们对接口感到同罪。

当我查看为库中的类设计的接口时,我曾经想过:哦,这些人都是专家。这必须是建立界面的正确方法。我无法理解的是,图书馆边界有其自身的需求和问题。一方面,图书馆完全不了解其客户的设计。并非每个边界都是相同的。有时甚至同一边界也有不同的穿越边界的方法。

这是查看界面设计的两种简单方法:

  • 服务拥有的接口。有些人设计每个接口以公开服务可以执行的所有操作。您甚至可以在IDE中找到重构选项,这些重构选项将使用您提供的任何类为您编写一个接口。

  • 客户端拥有的接口。ISP似乎认为这是正确的,而拥有服务是错误的。您应该在考虑客户需求的情况下分解每个接口。由于客户端拥有该接口,因此应该对其进行定义。

那谁是对的?

考虑插件:

在此处输入图片说明

谁拥有这里的接口?客户?服务?

证明两者。

这里的颜色是图层。红色层(右)不应了解绿色层(左)。可以更改或替换绿色层而无需触摸红色层。这样,任何绿色层都可以插入红色层。

我喜欢知道应该知道什么,不应该知道什么。对我而言,“最了解什么?”是唯一最重要的体系结构问题。

让我们澄清一些词汇:

[Client] --> [Interface] <|-- [Service]

----- Flow ----- of ----- control ---->

客户就是使用的东西。

服务是所使用的东西。

Interactor 碰巧都是。

ISP说,断开客户端的接口。很好,让我们在这里应用它:

  • Presenter(服务)不应该指示Output Port <I>接口。该接口应缩小到所需的范围Interactor(在此充当客户端)。这意味着关于,Interactor并且要遵循ISP 的接口KNOWS 必须随之更改。这很好。

  • Interactor(在此充当服务)不应该指示Input Port <I>接口。该接口应缩小为Controller(客户端)所需的接口。这意味着关于,Controller并且要遵循ISP 的接口KNOWS 必须随之更改。这是不对的

第二个是不好的,因为红色层不应该知道绿色层。ISP错了吗?好吧 没有原则是绝对的。在这种情况下,喜欢界面来显示服务可以执行的所有操作的傻瓜被证明是正确的。

至少,如果Interactor除了该用例需要之外没有做任何其他事情,那么它们是正确的。如果这些Interactor事情适用于其他用例,则没有理由对此Input Port <I>有所了解。不知道为什么Interactor不能只关注一个用例,所以这不是问题,但是事情还是会发生。

但是input port <I>接口根本无法将自己作为Controller客户端的奴隶,而这是一个真正的插件。这是一个“图书馆”边界。在发布红色层数年之后,完全不同的编程商店可能正在编写绿色层。

如果您越过“库”边界,即使您不拥有另一侧的接口,但仍感觉需要应用ISP,您将必须找到一种方法来缩小接口而不更改接口。

实现这一目标的一种方法是适配器。将其放在类似Controler和的客户端之间Input Port <I>。该适配器接受Interactor作为Input Port <I>和代表它的工作吧。但是,它仅Controller通过角色接口或绿色层所拥有的接口公开客户想要的东西。适配器本身并不遵循ISP,而是允许使用更复杂的类Controller来享受ISP。如果适配器的数量少于Controller使用它们的客户端的数量,并且当您处于跨越库边界的异常情况下并且尽管已发布但库不会停止更改时,这很有用。看着你的Firefox。现在,这些更改只会破坏您的适配器。

那么这是什么意思?老实说,这意味着您没有提供足够的信息让我告诉您您应该怎么做。我不知道是否不遵循ISP会导致您出现问题。我不知道遵循它是否最终不会给您带来更多问题。

我知道您正在寻找简单的指导原则。ISP试图做到这一点。但这还没有说很多。我相信。是的,在没有充分理由的情况下,请不要强迫客户依赖他们不使用的方法!

如果您有充分的理由(例如,设计一些可以接受插件的东西),请注意没有遵循ISP原因的问题(在不破坏客户的情况下很难更改)以及减轻它们的方法(保持Interactor或至少Input Port <I>专注于一个稳定的解决方案)用例)。


感谢您的输入。我有一个具有多个客户端的服务提供模块。它的名称空间在逻辑上具有一致的边界,但是客户需求跨越了这些逻辑边界。因此,根据逻辑边界划分名称空间对ISP毫无帮助。因此,我已根据客户需求将命名空间进行了划分,如问题图中的所示。但是,这使得它依赖于客户端以及将客户端耦合到服务的不良方式,因为可以相对频繁地添加/删除客户端,但是服务中的更改将很小。
work.bin

我现在倾向于使用提供胖接口的服务,就像它的完整名称空间一样,它取决于客户端是否可以通过客户端特定的适配器访问这些服务。用C术语表示,它将是客户端拥有的函数包装文件。服务中的更改将强制重新编译适配器,但不一定重新编译客户端。.. <contd>
work.bin

<contd> ..这肯定可以使构建时间最短,并以运行时为代价(调用中间包装函数)保持客户端与服务之间的“松散”耦合,增加代码空间,增加堆栈使用量,并可能增加思维空间(程序员)来维护适配器。
work.bin

我当前的解决方案现在可以满足我的需求,新方法将花费更多的精力,并且很可能违反了YAGNI。我将不得不权衡每种方法的利弊,并决定采用哪种方法。
work.bin

1

所以这一点:

existent clients are unaffected by the addition (or deletion) of more clients.

放弃您违反了另一个重要原则,即YAGNI。当我有数百个客户时,我会在意的。先想一想,然后发现您没有任何其他客户可以使用此代码来达到目的。

第二

 partitioning depends on the nature of clients

为什么您的代码不使用DI,依赖项反转,什么也不做,库中的任何内容都应该取决于客户端的性质。

最终,看起来您需要在代码下附加一层才能满足重叠内容的需求(DI,因此您的前端代码仅取决于此附加层,而您的客户仅取决于您的前端接口),这使您击败了DRY。
这将是您真正的想法。因此,您可以制作与其他模块下的模块层中使用的相同的东西。通过这种方式,您可以在下面实现以下功能:

对于任何客户端,仅可见其所需的数据和API;模块的其余名称空间对客户端是隐藏的,即遵守接口隔离原则。

在多个头文件中不重复声明,即不违反DRY。模块M与其客户端没有任何依赖关系。

客户端不受模块M未被其使用的部分中所做的更改的影响。

现有客户端不受添加或删除更多客户端的影响。


1

在定义中始终重复声明中提供的相同信息。这就是这种语言的工作方式。同样,在多个头文件中重复声明不违反DRY。这是一种相当常用的技术(至少在标准库中)。

重复文档或实现将违反DRY

除非客户代码不是由我编写的,否则我不会为此烦恼。


0

我否认我的困惑。但是,您的实际示例为我提供了解决方案。如果我可以用自己的话说:模块中的所有分区M中的与任何和所有客户端都具有多对多的排他关系。

样本结构

M.h      // fat header
 - P1    // Partition 1
 - P2    // ... 2
   - P21 // ... 2 section 1
 - P3    // ... 3
C1.c     // Client 1 (Needs to include P1, P3)
C2.c     // ... 2 (Needs to include P2)
C3.c     // ... 3 (Needs to include P1, P21, P3)

h

#ifdef P1
#define _PREF_ P1_             // Define Prefix ("PREF") = P1_
 void _PREF_init();            // Some partition specific function
#endif /* P1 */

#ifdef P2
#define _PREF_ P2_
 void _PREF_init();
#endif /* P2 */

#if defined(P21) || defined (P2) // Part 2.1
#define _PREF_ P2_1_
 void _PREF_oddone();
#endif /* P21 */

#ifdef P3
#define _PREF_ P3_
 void _PREF_init();
#endif /* P3 */

麦克

在Mc文件中,实际上不必使用#ifdefs,因为只要定义了客户端文件使用的功能,放在.c文件中的内容就不会影响客户端文件。

#include "M.h"
#define _PREF_ P1_        
void _PREF_init() { ... };

#define _PREF_ P2_
void _PREF_init() { ... }

#define _PREF_ P2_1_
void _PREF_oddone() { ... }

#define _PREF_ P3_
void _PREF_init() { ... }

C1.c

#define P1     // "invite" P1
#define P3     // "invite" P3
#include "M.h" // Open the door, but only the invited come in.

void main()
{
    P1_init();
    //P2_init();
    //P2_1_oddone();
    P3_init();
}

C2.c

#define P2
#include "M.h

void main()
{
    //P1_init();
    P2_init();
    P2_1_oddone();
    //P3_init();
}

C3.c

#define P1
#define P21
#define P3  
#include "M.h" 

void main()
{
    P1_init();
    //P2_init();
    P2_1_oddone();
    P3_init();
}

同样,我不确定这是否是您要的内容。所以带一点盐。


麦克长什么样?您定义P1_init() P2_init()吗?
work.bin

@ work.bin我认为Mc看起来像一个简单的.c文件,只是在函数之间定义了命名空间。
Sanchke Dellowar '01

假设C1和C2存在-这是什么P1_init(),并P2_init()链接?
work.bin

在Mh / Mc文件中,预处理器将替换_PREF_为最后定义的内容。因此_PREF_init()将是P1_init()由于最后一个#define语句。然后,下一个define语句将PREF设置为P2_,从而生成P2_init()
Sanchke Dellowar'1
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.