面向对象封装性小结

隐藏

面向对象与面向过程函数与数据的关系

简单来说,面向过程中函数与数据之间是相互独立的, 函数只考虑其功能,对于作用的数据是什么其实关系是比较弱的,

这也是面向对象相对面向过程易于实现较复杂的工程代码的原因之一, 而面向过程相对面向对象而言实现较简单的工程代码更为合适。

以下举例说明,如下的c函数:

double sum(double a, double b) {
    return a + b;
};

如上函数用于求和,对于输入的数据约束只体现在输入的数据类型上,要么是double类型,要么是可以转换成double的其他类型。

这即为数据与函数相互独立,函数仅考虑实现怎样的功能,输入可以是什么,输出是什么

然而这样的函数缺少对可作用于哪些数据的必要说明,也就是可能会误用

例如:

sum(duck.count, 2.3);

其中duck.count指鸭子的数量(int),2.0即为一个自然数,

对于duck.count而言,sum函数真正想要的意义或许是求鸡与鸭的总数sum(duck.count, chicken.count)

而对于参数2.3而言,或许是为了求两个自然数的和sum(5.6, 2.3)

然而事实上,并没有任何约束阻止sum(duck.count, 2.3),因为输入类型确实是double或者可转化为double。

以上sum函数例子较为简单,但依然可以以此说明函数并不是对所有数据都有意义的,仅对某些数据有意义。

事实上sum函数示例可能不太合适,这里只是比较方便举例,
更为合适的例子例如打电话这一动作仅适合作用于通讯录数据,而不适合职工工号、邮编、相册之类的数据)

对于面向过程,如果函数再复杂一些,数据再多一些, 这样就带来了一个问题,当看到一个函数的时候,无法快速得知该函数用于怎样的数据。 当看到一个函数接口(函数声明),要想理解函数,或许还得查看其实现(函数定义) 或者文字说明每个输入的意义是什么,并查找匹配的输入数据结构。

总之,由于没有面向对象语言机制,只能通过命名规则或者放在同一块区域,或者比如注释说明等。

而面向对象中以C++实现求鸡鸭总数为例,可以通过如下方式将数据与相关的函数组织在一起:

class Example {
public:
    int totalCount() {
        return duck_num + chicken_num;
    }
private:
    int duck_num;
    int chicken_num;
};

example类将数据与函数组织在了一起 这样通过调用该类的totalCount()接口明确地表明了函数功能是统计鸡与鸭的总数。

面向对象就提供了这样一种契约:类中的成员函数或多或少与类中的数据相关。

面向对象与面向过程在设计思路上也有差别, 面向过程关注过程(函数),功能的实现,面向对象则更关注数据,

实际上我们可以发现数据才是我们真正想要的,函数不过是为了获得我们想要的数据,处理数据的手段, 真正重要的是数据本身,关注数据结构的设计更为重要。

注:以上可能只能算基于对象而不是面向对象,因为没有涉及继承、多态,甚至对于数据封装都还没涉及,
只能算是代码组织形式上将数据与函数组织在一起。关于数据封装真正的威力、更深入的内容在后续内容中探讨。

面向对象数据封装的优势

以上部分探讨了数据与函数的相互关系, 并通过类将数据与函数组织在一起,

但是似乎数据设置为公有或者私有并无所谓啊? 为什么面向对象中要特意设计public(公有),private(私有)修饰符? 为什么很多类中数据要设置为私有呢? 私有数据实现的数据封装到底有何有意义呢?

以下从三个不同的角度作为例子探讨:

数据封装与Debug

所谓数据封装,就是规定了只有某些函数才有权利对该数据产生影响

以C++为例:

当我们看到private数据时,我们就能清楚地知道,只有该类的非const函数以及明确声明为该类的友元才有资格修改该数据;

当我们看到protected数据时,我们也很清楚,除了该类的非const函数以及友元,受影响的范围还扩大到了所有子类。

但至少可修改private数据以及protected数据的函数是有限的,是可以追溯的。

因为影响数据的函数范围有限,可追溯的,那么当数据出现异常值的时候,我们可以明确地仅查看有限的几处函数实现代码,

因此Debug错误也相对更为容易,也更不易出错。

所以面向对象的数据封装特性有助于组织更健壮、可维护性更好的代码

这也是面向对象设计更适合于复杂的大型代码项目的原因。

此外,类似如下将数据暴露出来的方式是不可取的:

class Clock {
public:
    Date* getDate() {
        return &date;
    };
private:
    Date date;
}

因为如上getDate()函数将获得一个可间接修改date数据的指针,这样破坏了数据封装,使得修改date的代码变得不可追溯。

数据封装与全局变量

对于面向过程的函数,其可能影响到的外部变量包括(传引用的)函参以及全局变量,

而在面向对象中,希望被影响到的这些外部变量都可以作为类的内部数据。

对于全局变量,用途比如两个函数之间相互协作时作为通讯的桥梁,

然而一方面这样的全局变量仅适合于某几个函数,其他函数如果影响了该全局变量将带来错误,而且不容易排查。

这也是为什么全局变量会带来不安全的因素的原因,所以要尽可能少用全局变量。

但面向过程就没有提供机制强制某个数据仅适用于某几个函数,

而面向对象就提供了这样的约束,或者说契约,只需将其作为一个类的私有数据,则仅有几个函数可以修改, 这样就避免了使用全局变量。

还有一点就是当全局变量过多时也可能会带来名字空间污染的问题。

数据封装与类不变式(invariant)

面向对象还有一个很有趣的优点,就是一个对象使用前必须先初始化,

再加上数据封装的特性,就能保证某些函数的前置条件必然成立,

用另一个说法是类维持了某个不变式,即类的生命期中恒成立的事情,

以下举例说明:

假设有如下面向过程的函数:

void doSomething(Object* p) {
    if(p == NULL)
        return

    p -> do_something();
}

void doAnotherThing(Object* p) {
    if(p == NULL)
        return

    p -> do_another_thing();
}

如上函数中调用某个对象指针执行命令,都得先检查对象指针是否指向了实际的对象,而不是null, p不为null是这些函数的前置条件,假设函数在一个for循环中,可想而知每次都得检查对性能影响很大。

而如果是面向对象可以如下设计(以下也是cpp中RAII惯用法):

class Handler {
public:
    Handler() {
        p = new Object();
    };
    ~Handler() {
        delete p;
    }
    void doSomething();
    void doAnotherThing();
private:
    Object* p=nullptr;
}

只要保证Handler中非const函数都不把指针p置nullptr, 就能保证“p指向实际的Object对象”这个不变式在对象生命期中恒成立。 包括如上析构函数并不需要检查p是否为非法值,可以放心地直接delete。

关于面向对象代码复用的补充

补充一点,如果从代码复用的角度考虑,实际上面向对象的代码复用并不方便。 实际上面向对象应该来说是提供更多的约束(契约),意味着灵活性、可复用性的降低。

举例说明,假设我的代码中有多处求数组的极小值的功能,例如最低温度,最低压力,最小速度。 如果将数据与实现代码组织到一块,例如:

class Temp {
public:
    double min() {
        //实现写在这里
    };
private:
    vector<double> tempVec;
};

class Pressure {
public:
    double min() {
        //实现写在这里
    };
private:
    vector<double> pressVec;
};

class Velocity {
public:
    double min() {
        //实现写在这里
    };
private:
    vector<double> veloVec;
};

如上必然会出现比较多的重复代码,不符合DRY(don’t repeat yourself)原则。坏处一方面重复代码意味着不必要的劳动力, 一方面将来如果改动一处代码说不定需要改动好几处代码,

然而如果从面向对象的角度考虑,如上代码复用很麻烦,因为各自实现中都用到了自己的私有数据成员, 且这些类并没有明显的相互继承的关系。

实际上上述求极小值的函数更适合于从泛型编程的角度考虑,实现为非成员函数。如果想要同时保证代码复用性以及面向对象的特性, 可以在类成员函数接口中调用这类非成员函数,例如:

class Temp {
public:
    double min() {
        return arr_min(tempVec); //调用非成员函数arr_min来复用代码
    };
private:
    vector<double> tempVec;
};

//其他类处理方式类似

总的来说,从面向对象的角度,代码复用有继承(is-a,是一个),以及包含(has-a,有一个)(某类作为另一个类的数据成员)的方式。

最方便的是继承,复用了接口和实现,然而继承意味着附加更强的类关系的契约:子类可以直接用基类的所有接口(里氏替换原则), 而这是非常强的约束条件, 例如有时候想在基类中添加一个接口,但是发现某个子类并不需要这样的接口,而其他好多子类需要,该如何是好? 所以面向对象的代码复用是非常麻烦的一件事情。 包含或私有继承则要求弱一些,

更细节的内容以后补充讨论面向对象的继承, 这里只简单提一下,以此说明面向对象的代码复用并不轻松,要想实现代码复用需要绕一些弯路, 这也是简单代码推荐尽可能不用面向对象的原因,因为面向对象优势体现不出,却使得代码绕得十分复杂。

总结

总的来说,我觉得面向对象的优势在于可维护性和安全性,易用性倒不是其优势,继承扩展了面向对象的可复用性,多态扩展了面向对象的动态特性,但和某些动态类型语言写代码比起来还是动态类型语言来得更方便。

面向对象的编程范式更重要的是提供了某些契约,让人扩展维护代码时更为自信,

  • 当看到私有数据时,我们明确地知道仅有某些函数可以修改其值。
  • 当我们将要了解某个类的用法时,仅需要关注其接口(公有函数)
  • 当看到私有函数时,我们明确地知道此函数用于该类辅助实现其他函数。
  • 当子类public继承自某基类时,我们也可以马上知道其复用了基类中的每个接口和实现。
  • 当我们看到纯虚函数时,我们知道该类作为抽象基类,子类中必定有该纯虚函数的实现,必然在某些地方多态调用。

不妨想想,当破坏了类的封装性,破坏了里氏替换原则,类实际上照样可以使用,但是这破坏了面向对象设计的契约,使得使用者可能会误用。

另外,由于面向对象提供了这么多的约束(契约),也就使得其使用灵活性降低,当一个类设计有问题时,想要添加某个功能会发现破坏了原有类设计的契约,也就不得不变着法子化简为繁。

所以不一定非得有对象才能编程(误),即使面向对象编程也尽可能精简类的设计,例如有如下的设计建议:

if (f needs to be virtual)
   make f a member function of C;
else if (f is operator>> or operator<<)
   {
   make f a non-member function;
   if (f needs access to non-public
       members of C)
      make f a friend of C;
   }
else if (f needs type conversions on its left-most argument)
   {
   make f a non-member function;
   if (f needs access to non-public members of C)
      make f a friend of C;
   }
else if (f can be implemented via C's public interface)
   make f a non-member function;
else
   make f a member function of C;

参见Scott Meyers的文章 How Non-Member Functions Improve Encapsulation

此文也说明了友元并没有破坏类的封装性,只是降低了类的封装性,即除了类自身的函数会对内部数据造成影响外,友元也会对内部数据造成影响,但至少可影响数据的函数范围是有限的,是可追溯的。

待填坑:

  • 面向对象的继承
  • 面向对象的多态
-----EOF-----

Categories: programming Tags: oop encapsulation