What & How & Why

C++面向对象高级编程(上)第一周

本页内容是我关于 Boolan C++ 开发工程师培训系列课程的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!


相关概念

C++的历史和版本

C++ 的演化过程 大致如下:

  • 1979年,C++ 作为C语言的扩充(C with Class)面世,这一阶段主要是添加 C 语言对 OOP 的支持。
  • 1983年,C++ 的正式诞生。
  • 1998年,C++ 语言成为美国国家标准和国际标准:C++ 98 标准(1.0)。
  • 2003年,C++ 03:C++ Technical Report 1 (TR1)。
  • 2011年,C++ 11:标准 2.0。
  • 2014年,C++ 14。

其中主流标准版本为 C++ 98 和 C++ 11。

C++的入门推荐读物

普遍意义上来说,熟练使用 C++ 意味着必须熟悉语法标准库,因此推荐书籍分为两类:

语法类:C++ Primer 5th / The C++ Programming Language / Effective C++
标准库:The C++ Standard Library / STL 源码剖析

头文件与类的声明

数据与函数

在 C 语言中,不论是 build-in 类型,还是 struct,都只能以变量的形式来表现数据:函数是不能放进结构体的。在 C语言的结构体中,所有的数据都没有访问的限制;这些变量可以被任意函数访问。

而在 C++ 中,class / struct 对数据和函数进行了整合,以对象的形式来访问。这种封装+数据抽象的手段使得我们可以设计指定的函数来处理指定的数据。

类的分类

设计类的时候,我们一般把类分为两种:

  • 数据中有指针的类
  • 数据中没有指针的类

这两种类的主要区别在于:没有指针的类,一般情况下我们可以让系统自动调用析构函数;但如果类中包含了指针,往往会涉及到内存管理问题,因此很多情况下需要自定义析构函数。

Output

C++ 通过 <iostream> 类来实现了新的输出手法:

cout << i << endl;
而如果想使用 C 中的 printf(), 需要包含 C 语言的是输入输出头文件:<cstdio>。在 C++ 中,所有继承自 C 语言的库都改了名字:去掉了后面的的 .h,前面加上了 c

头文件的防卫式声明

我们经常会遇到这样的情况:一个头文件被不同的程序文件所引用。

如果不对头文件进行任何处理,那么只要有两个或以上的文件包含同一头文件,那么编译器会返回 redefined 的错误。

为了处理这个问题,C++ 引入了 #ifndef# 预处理器来处理这个问题。具体的格式如下:

#ifndef FILE_H //test if File has been defined
#define FILE_H // if not, process the definition work; else jump to the endif.
/* ... Declarations etc here ... */
#endif
每个包含了 file.h 的文件编译时都会进行检查,确保 file.h 只编译了一次。

头文件的布局

除了上述的预处理器以外,我们把头文件剩余的内容分为三个部分:前置声明Forward Declaration)、类声明类定义:

// forward declaration
class A;
class B;
//class declaration & definition
class A 
{
    /*function declaration*/ 
}
//member function definition outside the class
void A::function...

类声明&定义部分是头文件的主要部分。我们在这个区域里进行类成员的声明和定义。而类的成员函数是可以在类中定义,也可以在类外定义的。在类外定义的成员函数需要加上 Scope 操作符。

Template 简介

我们常常遇到一种情况:需要的函数功能完全相同,唯一不同的只是参数的类型。为了使得函数可以应对不同类型的参数,C++ 提供了模板来解决这个问题。

我们可以通过模板来声明一种“不确定”的类型:

template<typename T> // T is a type name, can be any type of arguments.
接下来,我们就可以用 T 类型来定义数据了:
class complex
{
    /*other codes*/
    T re, im; // define re, im as T type
    /*other codes*/
}
然后,我们就可以在建立的对象的时候同时定义我们希望指派给对象的参数类型了:
complex<double> c1(2.5, 1.5); //the object data type is double
complex<int> c2(2, 6); //the object data type is int

Inline 函数

在 2.4.中我们看到类的成员函数可以在类内部定义,也可以在内外部定义。而在类内部定义的函数,都是 Inline 函数

Inline 函数是在编译阶段就会执行的函数,省去了一般函数 overhead 的阶段,因此速度非常快。对于一些大量反复使用的,结构不是很复杂的函数,我们一般都使用 Inline 函数。

如果要声明一个函数为 Inline,我们需要加上 Inline 关键字:

Inline func
{
    definitions here...
}

Inline 关键字只是我们对编译器提出的建议会不会将函数视为 Inline 是由编译器决定的

Access level

C++ 通过两个关键字来实现封装:publicprivate。外部只能访问 public 中的内容。

private 中我们存放数据、以及功能性的函数; 而 public 我们用于存放提供给用户使用的函数,即 Interface。具体写的时候,这两类型的内容在类中并没有指定的顺序。

构造函数

构造函数 (Constructor)是用于类对象初始化的一种函数;这个函数有以下特点:

  • 构造函数的名字与类相同
  • 构造函数没有返回类型
  • 构造函数可以重载

构造函数的初始化

构造函数的初始化大致分为两类:直接初始化初始化后再赋值

直接初始化的格式可以写成:

complex (double r = 0, double i = 0) : re (r), im(i);
其中括号内的内容定义了构造函数的 parameter 的类型,而等号后面的值则是构造函数默认 argument。冒号后面的内容则是指定了要对哪些类成员进行初始化。

而初始化后再赋值是先用构造函数对变量初始化,在通过拷贝的方式对变量赋值。如果用这种方式复写上面的例子,我们可以把上例写成这样:
complex (double r = 0, double i = 0)
{
     re = r;
     im = i;
}
这样写是完全正确的,但是效率上可能比第一种要差:在这种初始化中,编译器对类成员先进行了初始化,然后再把值交给了类成员。

构造函数的重载

跟普通函数一样,为了对应不同的初始化参数,构造函数也可以有好多个。调用构造函数的时候,我们只需要输入对应的参数,系统就会自动调用对应的构造函数。

对于函数的重载(包括构造函数),在我们看来是一个函数,实际上在编译器中,是两个完全不同的函数。

Singleton 设计模式简介

绝大部分情况下我们都将构造函数放置于 public 内。但有一种情况很特殊,它将构造函数和对象(静态)同时放到了 private 区域内, 然后设计一个 static 类型的函数去访问已经存在的静态对象。从实现手段上来看,这个类始终在强调对象的唯一性;也就是说,不允许其他手段创建类实例。我们把这样的类的实现手段称为 Singleton 设计模式。

成员函数

常量成员函数

常量成员函数是指被 const 修饰的成员函数,表明函数不能对元素进行写操作,通常用于只读的功能:

double real() const { return re; }; //notice the position of const.
const 是我们设计程序中需要考虑的一个重要的问题。const 代表了一个态度,说明设计者不希望用户通过函数修改数据。因此,如果程序不牵涉到任何改变元素的操作,我们都应该加上 const 关键字。而除了这个,const 还可以提高程序的兼容性。假设我们去掉上面语句的 const
double real() { return re; };
如果用户以一个 const 的对象去调用:
const complex c1(2,1);
那么很可能就会出问题了。用户希望自己的数据不被修改,而设计者认为函数是可以修改用户的数据的,因此就矛盾了。如果这里加上 const,我们不但能够处理 const 的对象,同时也能处理 non-const 的对象。

友元函数

友元函数并不是类的成员函数,但友元函数可以访问私有变量,只需要在类中用关键字 friend 声明即可:

firend complex& __doapl (complex*, const complex&);

对于相同类的不同对象,这些对象互为友元。

参数传递

参数传递分为值传递(Pass by Value)和引用传递(Pass by Reference)。

值传递在传递过程中,先对 argument 进行拷贝, 然后复制到 parameter 中。 而引用传递是直接传递 argument 的引用。因此,在绝大部分情况下,引用传递的效率要远远高于值传递(省去了复制的过程)。不难看出,引用传递也是设计程序中一个值得注意的关键点:

  • 引用传递的效率高于值传递。
  • 引用传递比指针传递的形式更加漂亮。
  • 引用传递可以直接在局部的 Scope 中修改 argument。
  • 如果不希望修改引用传递,可以传递 reference to const。

返回值传递

返回值的传递也同样分为值传递和引用传递。不过相比参数传递,返回值传递还有两个需要注意的地方:

  • 有些类型的传递只能用引用传递,比如 iostream
  • 如果需要传递的返回值是局部变量绝不能使用引用传递。

运算符重载

运算符重载是我们用自己的方式定义运算符对自定义类型的操作。在这里,运算符重载的本质实际上是函数的重载

按照课程中的例子,运算符被分为两种部分:成员函数版本和非成员函数版本。

成员函数重载


成员函数版本的运算符重载的声明如下:

inline complex&
complex::operator += (const complex&r)
{
    return __doapl(this, r);
}
我们注意到这里只有一个 parameter。其实这个函数隐式包含了另外一个 parameter:thisthis 是一个永远指向当前对象的指针。我们在对类成员进行 += 之类的运算的时候,实际上运算符的左边就是类成员;因此第一个参数是 this

这里还有一点需要注意的是,这个函数是有返回值的。按理说我们传的引用,那么在函数里直接修改就好了,为什么要添加一个返回值?

设想一下我们有 c1, c2, c3 三个对象;如果我们进行这样的计算:
c1 + c2 + c3;
按照结合律,应该是 c1 + (c2 + c3),这就要求 c2 + c3 必须要有一个返回值作为下一个加法的右边部分;如果不为函数设置返回值,我们是拿不到一个结果作为右边的部分的。

非成员函数重载

而对于教程中另外一类的运算符重载,我们就不用写成成员函数的版本了,因为相关操作并不需要对私有变量的操作来实现,比如:

inline complex
operator + (const complex& x, const complex& y)
{
    return complex (real(x) + real(y), imag(x) + imag(y));
}
上例我们通过了 public 中的函数操作了类成员,重定义了 + 对类对象的使用。

这里还需要注意的是,return 的时候我们使用了 typename() 这样类似的语句。这样的语句的作用是创建一个指定类型的临时对象;它的生命周期在下一行就结束了,只用于作为返回值。

输出流运算符的重载

输出流运算符 « 的重载必须写成全局函数;因为 « 的左边必须是一个 iostream 的对象。

当有连续输出对象的时候,我们也不能用 const 来修饰这个函数的重载,因为 ostream 对象内容一直在变。