欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C++程序的耦合性设计

程序员文章站 2022-07-02 12:18:38
声明:本文部分采用和参考《代码里的世界观-通往架构师之路》中内容,可以说是该书中耦合性一章的读后感,感谢该书的作者余叶老师的无私分享。 1.什么是耦合? 耦合其实就是程序之间的相关性。 程序之间绝对没有相关性是不可能的,否则也不可能在一个程序中启动,如下图: 这是一个Linux中socket TCP ......

声明:本文部分采用和参考《代码里的世界观-通往架构师之路》中内容,可以说是该书中耦合性一章的读后感,感谢该书的作者余叶老师的无私分享。

1.什么是耦合?

耦合其实就是程序之间的相关性。

程序之间绝对没有相关性是不可能的,否则也不可能在一个程序中启动,如下图:

C++程序的耦合性设计

 

 

这是一个linux中socket tcp编程的程序流程图,在图中的tcp服务器端,socket()、bind()接口、listen()接口、accept()接口之间肯定存在着相关(就是要调用下一个接口程序必需先调用前一个接口),也就是耦合,否则整个tcp服务器端就建立不起来,以及改变了bind()中的传入的数据,比如端口号,那么接下来的listen()监听的端口,accept()接收连接的端口也会改变,所以它们之间有很强的相关性,属于紧耦合。所以耦合就是代码的相关性,如果还不明白,也没关系,继续看下去,相信你会懂的,哈哈。

 

2.耦合的形式

(1)数据之间耦合

    在同一个结构体或者类中,如:

typedef struct person

{

int age;

char* name;

}person;

class person

{

    private:

        int age_m;

        bool namepresent_m;

        std::string name_m;

};

在上面的结构体和类中,年龄和名字两个基本数据单元组合成了一个人数据单元,这两个数据之间就有了耦合,因为它们互相知道,在一些操作中可能需要互相配合操作,当然这两种数据耦合性是比较低的,但是namepresnet_m是判断name_m是否存在的数据,所以这两个数据之间耦合性就高很多了。

 

(2)函数之间的耦合性

    函数如果在一个类中也会互相存在耦合性,比如下面例子:

    class person

{

    public:

        int getage(){return age_m;};

        void setage_v(int age){age_m = age;};

        std::string getname(){return name;};

        void setname(std::string name){name_m = name;};

   

    private:

        int age_m;

        std::string name_m;

};

其中的getage()和setage_v()接口操作的是同一个数据,能够互相影响,存在着很明显的耦合,但是getname()和getage()两个接口相关性就不明显的,但是也会存在耦合性,因为getname()能够访问的类中数据,getage()也能访问,如果程序员编写代码不注意,也会把在两个接口中调用到了相同数据,互相造成了影响。

除了封装在一个类中的函数之间有耦合性,外部的函数也会根据业务需要产生耦合,比如刚开始说的网络编程的例子中,socket()、listen()、bind()、accept()之间就产生了很强的耦合。

以及在两个类中,比如:

class fruit

{};

class apple:fruit

{};

class fruitfactory

{

    public:

        furit* getfruit(){fruit* fruit_p = new apple(); return fruit_p; }

};

class person

{

    public:

        void eatfruit(fruit* furit);

};

fruitfactory fruitfactory;

fruit* fruit = fruitfactory.getfruit();

person person;

if (fruit != null)

{

person.eatfruit(fruit);

}

    上面的fruitfactory和person两个类之间产生了数据耦合,而getfruit()和eatfruit()两个接口之间也产生了耦合。

 

(3)数据与函数之间的耦合

    从(2)中的程序也能看出,eatfruit()这个接口和fruit这个数据产生了耦合,如果不先创建fruit,那么接下来的eatfruit()操作也没有意义,如果强制调用,甚至可能造成程序崩溃,产生coredump。

    上面例子的耦合还是比较明显的,有一些不明显的耦合,如下:

    speaker speaker;

speaker.poweron() ;
speaker.playmusic() ;

表面上是 playmusic()对poweron()有依赖性,是函数之间的耦合,但背后的原因是 poweron()函数让播放器处于通电状态:

poweron(){
this.ispoweron = true;

}
//只有通了电,播放器才能正常播放音乐
playmusic() {
if(this.ispoweron)

play();

}

这两个函数是通过 this .ispoweron 这个数据进行沟通的 。 这本质上还是数据和函数之间的耦合。

 

3.耦合问答

    经常听到“解耦”这个词,那是不是耦合都是不好的?

    这个要根据代码的耦合性特点来分析,首先看一下下面几个问题吧,看完,相信大家也有答案了。

(1)耦合可以消除吗?

经过上面那么多例子,大家也意思到耦合无处不在,所以是不能消除的。

(2)那既然不能消除,那解耦的意思是什么?

解耦就是降低程序模块之间的耦合性。

(3)那解耦的目的是什么?

解耦的目的是为了增强一个模块的可移植性、可复用性,就像人的肾可以移植,但是血管却移植很难移植,为什么,因为血管这个“模块”和身体这个“系统”之间的“耦合性”太强,关联地方太多,移植工作量超大。以及减少模块与外部模块的关联,内部模块的修改对外部影响比较少,这也和用人的肾移植和血管移植类似,每个器官中都有血管,一旦移植,所有器官都要动,耗时耗力。

那可移植和可复用有什么好处?比如我们用程序在电脑写出了一个俄罗斯方块的游戏,后来客户也要在手机端做这个游戏,这时候就能够复用电脑端的俄罗斯方块的游戏策略和逻辑,只需要把界面替换掉就行,业务和策略部分基本不用修改,如果电脑端的游戏界面和业务逻辑的程序“浑然一体”,那几乎就需要重新翻新一遍。

(4)那所有程序都需要解耦吗?

不是的,有些时候,我们反而需要增强程序的耦合性,这就是平时说的“高内聚,低耦合”,其中的内聚其实也是耦合,或者说程序的相关性。如下面例子:

在界面上的不同位置要显示多种不同的图形,如三角形、正方形等 ,这里所有的信息浓缩在下面两个数组里 。

一个是 shape 数组 : { ”三角形”,”正方形",”长方形”,"菱形”} 。

一个是 position 数组 : { pointl, point2 , poin口 , point4 } 。

两个数组的元素个数是一样多的,它们是一对一的关系 。 比如, 第一个 positio口就是第一个 shape 的位置信息 。 那么代码如下:

for(int i = o; i <count, i++){

draw(shape[i] , position[i]);

}

这样做方便但不好!它会为以后的修改埋藏隐患 。 因为两个数组元素之间的对应关系,并没有得到正式承认。这时候就需要增强它们之间的关联,把隐式的关联转成显式的关联。如下:

dictionary die = {“三角形”: pointl,

“正方形”: point2 ,
“长方形”: point3 ,
“菱形” : point4 } ;
//draw()函数再也不用担心会画错了
foreach(var item in dic){
. draw(item.key,item.value);
}

平时编程中使用的结构体和类封装也是同样,把一些有关联的数据和方法组合起来,显式增强它们之间关联性,方便使用和移植。

4.怎么解耦?

    (1)贯彻面向接口编码的原则

    程序不可能没有改动的,但是尽量把改动放在一个模块的内部,接口不要变,就算需要改变,最好使用适配器模式增加一个适配程序。因为接口就是一个程序与外部的关联处,保持接口不变,就是保持该模块和外部模块的耦合性不变,这样才能保证它的可移植性可重用以及不被外部模块的修改而影响。

    (2)保证一个模块的可测试(单元测试)

    如果一个模块是可以单独进行单元测试的,意味着它可以移植到其他程序上,耦合性低。

    (3)可以学习一下设计模式的设计思想。

    (4)让模块对内有完整的逻辑

    解耦的根本目的是拆除元素之间不必要的联系,一个核心原则就是让每个模块的逻辑独立而完整。其中包含两点,一是对内有完整的逻辑 , 而所依赖的外部资源尽可能是不变量;二是对外体现的特性也是“不变量”(或者尽可能做到不变量),让别人可以放心地依赖我。有的函数光明磊落,它和外界数据的沟通仅限于函数的参数和返回值,那么这种函数给人的感觉可以用两个字形容:靠谱。它把自己所需要的数据都明确标识在参数列表里,把自己能提供的全集中在返回值里。如果你需要的某项数据不在参数里,你就会侬赖上别人,因为你多半需要指名道姓地标明某个第三方来特供;同理,如果你提供的数据不全在返回值和参数里,别人会依赖上你 。有的函数让人觉得神秘莫测,规律难寻:它所需要的数据不全部体现在参数列表里,有的隐藏在函数内部,这种不可靠的变量行为很难预测;它的产出也不集中在返回值,而可能是修改了藏在某个不起眼角落里的资源。 这样的函数需要人们在使用过程中和它不断地磨合,才能掌握它的特性。前者使用起来放心,而且是可移植、可复用的,后者使用时需要小心翼翼 ,而且很难移植。

5.耦合优化的例子

实例一

在上面介绍的一个例子:

poweron(){
this.ispoweron = true;

}
//只有通了电,播放器才能正常播放音乐
playmusic() {
if(this.ispoweron)

play();

}

这里的poweron()接口和playmusic()接口在同一个类中,ispoweron变量是内部私有变量,这样写法是没问题。如果ispoweron是一个全局变量,而playmusic()接口中程序相对复杂一些,可能就会在外部调用时候忘记了先调用poweron给ispoweron设置为true。为了让playmusic()的接口逻辑独立而完整,就需要显式给playmusic()传入ispoweron参数,如playmusic(bool ispoweron),即使是在一个类中,为了防止在外部调用时建议在使用playmusic()前添加一个判断ispoweron接口,如:

speaker speaker;

if (speaker.ispoweron())

{

    speaker.playmusic();

}

这样在后来有人修改该部分程序时,知道先通电在播放音乐。

 

实例二

一个人要读书 :

person person = new person();
person.readbook(book);
//readbook 函数里的逻辑如下:
void readbook( book book) {
//要求人看书之前要先戴眼镜,所以第一步必须是戴眼镜的动作
wearglasses(this.myglasses) ; //person类中有一个名为 myclasses的成员
read (book) ;
如果这个人没有眼镜,this.myglasses 变量为 null ,直接调用person. readbook(book);会出现异常,怎么办呢?

优化一:通过成员函数注入

于是打个补丁逻辑吧,在 readbook 之前先给他配副眼镜 :
person.setmyglasses(new glasses()); //先为person 配副眼镜
person.readbook(book);
如上,加上了 person.setmyglasses(new glasses());这行代码,这个 bug 就解决了。 可解决得不够完美,因为这要求每个程序员都需要记住调用 person.readbook(book)之前,先给成员赋值:
person.setmyglasses(new glasses());
这很容易出问题。 因为 readbook是一个 public 函数,使用上不应该有隐式的限定条件。

优化二:通过构造函数的注入
我们可以为 person的构造函数添加一个 glasses 参数:
public person (glasses glasses) {
this.myglasses = glasses ;

}
这样, 每当程序员去创建一个 person 的时候,都会被逼着去创建一个 glasses 对象 。 程序员再也不用记忆一些额外需求了。这样逻辑便实现了初步的 自我完善。
当 person类创建得多了,会发现构造函数的注人会带来如下问题 : 因为 person 中的很多其他 函数行为,如吃饭、跑步等,其实并不需要眼镜,而喜欢读书的人毕竟是少数,所以person.readbook(book);这句代码的调用次数少得可怜。为了一个偏僻的 readbook 函数,就要让每个person都必须配一副眼镜(无论他读不读书),这不公平。也对,我们应该让各自的需求各自解决。
那么,还有更好的方法吗? 下面介绍的“优化三”进一步解决了这个问题。

优化三:通过普通成员函数的注入
于是可以进行下一步修改:恢复为最初的无参构造函数,并单独为 readbook 函数添加一个glasses参数:
void readbook(book book , glasses glasses ) (
wearglasses(glasses);
read (book);
对该函数的调用如下 :
person.readbook(book , new glasses ());
这样只有需要读书的人,才会被配一副眼镜,实现了资源的精确分配。
可是呢,现在每次读书时都需要配一副新眼镜:new glasses(),还是太浪费了,其实只
需要一副就够了 。

优化四:封装注入

好吧,每次取自己之前的眼镜最符合现实需求 :
person.readbook(book,person.getmyglasses()) ;
这又回到了最初的问题:person.getmyglasses()参数可能为空 ,怎么办?

干脆让 person.getmyglasses()封装的 get 函数自己去解决这个逻辑吧:
glasses getmyglasses(){
if(this.myglasses==null)
this.myglasses =new glassess();
return this.myglasses;

}
//然后返回到最初的readbook代码。readbook里的逻辑是默认取自己的眼镜
void readbook(book book) {
wearglasses(this.getmyglasses()) ;
read(book);

}
对 readbook 函数的调用如下 :
person.readbook(book);
这样每次读书时,就会复用同一副眼镜了,也不会影响 person 的其他函数。
嗯,大功告成了。最终的这段readbook代码是最具移植性的,称得上独立而完整。
可以看到,从优化一到优化四,绕了一圈,每一步修改都非常小,每一步都是解决-个小问题,可能每一步遇到的新问题是之前并没有预料到的。优化一到优化三分别是3种依赖注入的手段:属性注入、构造函数注入和普通函数注入。它们并没有优劣之分,只有应用场合之分,这里我们是用一个案例将它们串起来介绍了。同时大家通过这个小小的例子也可以体会到:写精益求精的代码,是需要工匠精神的。让每一个模块独立而完整,其内涵是丰富的 。 它把自己所需要的东西全列在清单上,让外界提供,自己并不私藏。这意味着和外界的关联是单向的,这样每个模块都变得规规矩矩,容易被使用。如果模块要被替换,拿掉时也不会和周围模块藕断丝连 。

6.这里有彩蛋

没有绝对好的程序,了解耦合性只是为了写出比较好的程序,但是在写程序中过于执着于耦合性,反而不美。不过,平时编写程序时候也要注意和思考,慢慢就能获得一些“感觉”,也就养成了良好的编程习惯。

 

写出好代码的途径,一是要有一定的知识积累,多看看书,站在前人的肩膀上,不仅仅是代码数量积累,二是对代码进行审计,审计自己代码找出自己一些不好的代码编写习惯,以后有意识去更改。