数据抽象

DinS          Written on 2017/10/31

一、抽象的概念

数据抽象是OOP的血肉,也就是基石的意思。
数据抽象的两个核心概念是类和对象。
理解这两个概念最好从类比开始,西方和东方的先贤都有论述:

西方有柏拉图的理念论。柏拉图认为,存在理念世界和现实世界两个世界。理念世界是完美的概念,现实世界是概念的摹本。
比如说椅子。椅子作为一种概念存在于理念世界,我们看到的现实中的椅子并不是椅子这个概念本身,而只是概念的摹本,一个个具体的椅子。现实世界中有四条腿的椅子,三条腿的椅子,红色的椅子,蓝色的椅子,木头椅子,塑料椅子,这些都是具体的椅子。
我们通过椅子的概念来理解椅子的摹本。椅子的概念中存在具体椅子的各个可能性,但是并没有被实现出来;具体的椅子具象化了概念的某些方面,但丧失了概念的可能性。

东方也有类似的思考,比如朱熹的理学,最著名的一句话是“理在气先”。
理就是事物的概念,气则是概念形成具体事物后的状态。
“未有此气,先有此理”,说的是先得有概念,才能够形成具体的事物。

翻译到OOP的数据抽象,类就是理念和理;对象就是摹本和气。
类是一种抽象的概念,对象则是根据这个概念形成的具体的事物。

二、类的语法形式

如何创建一个类?使用class
通常而言我们把类的声明放到.h中,类的实现放到.cpp中。

具体到水果商人的例子中,每一种水果都可以看作一个类。
苹果是一个概念,具体的苹果是由苹果的概念具象化的。
让我们尝试写一个苹果类。下面的代码初看有些复杂,但是包括了类的大部分必要的内容和套路,将详细讲解。

#pragma once告诉编译器只编译一次该.h,防止重复编译。
使用关键字class后跟类型名称来声明一个类。

一个类中有两部分,一个是private表示私有,另一个是public表示公有。Private里面的内容只有类自己能访问,使用类的代码只能访问public里面的内容。
那么问题就来了:为何需要公有和私有的区分?答案是抽象。通过只把想让外界知道的部分(接口)和不想让外界知道的部分(实现)隔离开来,类的设计者获得了将来改变类实现的灵活性。只要接口不变,类的实现可以随意变化而不会影响到使用类的其他代码。也就是说将变化隔离开了。
原则上所有成员变量均应该放到private部分。成员函数的话只把对外接口放到public,内部使用的一些过程函数也放到private部分。

成员变量的声明方法跟正常的一样。留意声明的顺序,在构造函数中会用到。
如果成员变量是const或者引用,则必须使用初始值列表才能初始化。一般而言不会在类内部声明这种变量。
如果是指针类型,则通常意味着要在构造函数中new在析构函数中delete,还要提供拷贝构造函数和重载赋值运算符。如果需要动态分配内存,优先使用容器类,可以避免很多麻烦。如果要用指针,使用智能指针shared_ptr。

类的Big-Three指构造函数、析构函数和拷贝构造函数。这三类函数对于类非常重要所以称为Big-Three。推荐做法是不管什么情况都要提供自己的版本而不是使用默认版本。如果默认版本可行就声明然后加个=default。
这样做的理由是C++煞费苦心地给程序员提供了构造和析构的时机,如果不使用就太可惜了:)。声明的写法是固定的,如上图所示。
当然如果类中只有内置类型、容器类和智能指针,则析构、拷贝构造和赋值都可以安全地使用默认版本。(记得在构造中使用make_shared来为shared_ptr初始化,make_shared可以用在初始值列表中)。

构造函数是在使用类建立对象时自动调用的函数,构造函数可以带参数也可以不带,如果不带称为默认构造函数。永远应该提供默认构造函数
构造函数可以有多个以适用不同的场景,我们应该提供多个构造函数,利用好难得机会不是吗?另外,优先使用初始值列表并且其顺序应该与成员变量声明顺序一致。具体原因跟编译有关,不深入了。
析构函数是在销毁对象时自动调用的函数,很显然析构函数没有参数和返回值。一个类只能够有一个析构函数。注意析构函数不能抛出异常。
拷贝构造函数调用的时机有三:1.定义对象时使用已有对象赋初始值;2.对象作为参数传入函数;3.对象作为函数返回值。由此可见也很重要。拷贝构造函数也是一种构造函数,所以优先使用初始值列表,注意事项相同。

c++一个优点是给予了程序员极大的灵活性,是少有的能够重载运算符的语言,应该利用这个优势。
赋值运算符是重中之重,如果类复杂的话一般需要提供自己的版本,如果涉及new和delete必须提供自己版本。当然使用智能指针可以避免许多动态内存的坑。重载赋值运算符还有一些套路,在实现部分再说明。

接口是外界与类交互的唯一方式,好的接口设计是好的类的前提。努力让接口complete and minize。接口设计是一门艺术。
函数后面有一个const,称为常量成员函数,如果这类函数试图修改成员变量,编译器将报错。任何不修改成员变量的成员函数都应该声明为const
定义在类内部的函数自动inline。所谓inline就是不作为函数而是直接展开代码,这样效率会提高一些(因为没有压栈出栈开销,与汇编有关系,感兴趣的可见《函数调用过程——汇编的视角》),但是代码长度会增加。
也可以显式声明inline,但是不推荐这么做。Inline并非强制性,只是告诉编译器一种想法。实际上现代编译器会自动判断哪些函数适合inline并执行,如果认为不适合即使声明了inline也会不予理睬。
推荐的做法是不显式声明inline,而是交给编译器处理。对于只有一两行的函数,直接在.h中实现即可(当然如果要把该.h交给第三方那么不应该如此处理)。

这里只有声明没有实现。实际上按照惯例.h中只应该出现接口声明,.cpp中再去实现。
还有一点需要说明,从名字可以看出5个接口中有4个都是用来操作私有成员的。这是因为私有成员不能直接访问只能通过接口访问。乍看起来这样做很麻烦,但是往后就可以看到这样做自然有它的道理。

.h到这里结束,再看看.cpp(假设这个.h叫做Fruit)

大图点这里

包含刚刚的头文件,这样才能找到对应的名字。标准库的输出用于测试。

先来看默认构造函数,当类建立对象时调用。
既然什么参数都没有,那么就把私有成员初始化为默认值即可。
注意这里没有使用初始值列表,而是直接在函数体中使用了赋值。这仅仅是出于演示的需要。优先使用初始值列表。
这里需要解释一下初始化和赋值的区别。对于内置类型而言,没什么不同。但是对于自定义的类而言,差别可能巨大。首先是效率的问题。初始化是直接将对象建立并拥有对应的值,这个过程只有一步。如果使用赋值,程序会先调用默认构造函数建立一个对象,然后依次将私有成员赋值,这个过程有两步。如果类比较复杂在效率上会有差异。更重要的区别是有些类只能初始化不能赋值,比如const成员,或者类没有默认构造函数。这时只能使用初始值列表。
而且使用初始值列表会让程序员的意图更加明显,也更加安全。使用初始值列表是现代C++的标志。

第二个是带参数的构造函数,使用了初始值列表来初始化。
具体写法就是在参数后面跟一个:,然后依次是私有成员加一个(),内部就是初始化的值。不同成员之间用,分隔。这种写法还是很清晰明了的
另外应该发现所有成员函数前面有个Apple::,只有加上这个编译器才能找到对应的类。

析构函数,并没有做实际的操作,因为内置类型销毁的话系统会自动处理。

拷贝构造函数也属于构造函数,所以可以使用初始值列表。
有意思的是值的来源,这里传入的参数必然是Apple类(固定写法),然而我们并没有通过类的接口访问私有成员,而是直接访问了。这是因为这个函数是类内部的函数,虽然传入的参数是一个类,但是依然可以直接访问。

重载赋值运算符时的套路就是:
1.检查自己给自己赋值的情况,固定写法。
2.如果不是那么逐一赋值,同理可以直接访问私有成员。
3.结束后返回*this。那么*this是什么?This是一个指针,指向类自身。
总体而言这个东西不常用,有个概念即可。

接口部分没什么特别的,就是设置成员变量,然后计算。
这里倒是有一件事值得一提:为什么不将总价设置为成员变量?
成员变量的生命周期与类一致,这就意味着通常而言会比较长。
因此本着节约资源的立场,我们应该尽量减少成员变量,只保留必要的。其他一些过程变量或者能够算出来的就不必设置为成员变量了。

三、使用类

这样我们的Apple类设计完成了,如何使用呢?
看下面这个例子。这个例子的用意更多的是展示Big-Three的相关内容。

CalcApple函数存在的唯一意义就是看一下拷贝构造函数和析构函数。

SetApple函数的用意在于对比拷贝构造函数,引用的话不会建立临时对象。

下面看一下主函数和输出情况:

主函数分成3段。
第一段使用了默认构造函数,可以看到对应的输出。然后使用了引用传参,拷贝构造函数没有被调用。

第二段使用了带参数构造函数,并且把这个对象作为参数传入函数,可以看到先调用了拷贝构造函数然后函数结束调用析构函数。

第三段使用了赋值方式的初始化,也是拷贝构造函数被调用,然后赋值并计算,从结果来看赋值运行正常。

至此我们就把类的最基本的内容掌握了。
自己写一个类也并不困难,但是至此你应该有一个重大的疑问:使用类并没有比FOP好多少啊?实际上应该是更复杂了才对。
试想我们要为每一种水果写一个类,然后还要判断输入文件决定建立哪个类,多麻烦啊?

这个怀疑是合理的。实际上类是OOP的基石,但也仅仅是基石而已。不能够只使用基石来建立宏伟的大厦。我们需要更多的东西,那就是继承,见《继承》。