What & How & Why

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

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


转换函数

C++ 中可以对 build-in 类型的数据进行自动转换。但当我们希望对自定义的类型进行自动转化的时候,我们就需要自定义一个规则来匹配类型。我们称这个匹配类型的方法为转换函数Conversion Function)。

转换函数可以通过两种不同的路径来实现:

  1. 改变对象的类型,使类对象的类型匹配表达式,也就是向外转化
  2. 改变表达式中其他 operand 的类型,使其匹配对象的类型,也就是向内转化

第一种方式是通过类型转化函数中改变对象的类型来实现的;而第二种方式则是通过构造函数来实现。

类型转化函数

C++ 中类型转化函数的使用方法和操作符重载类似。我们可以通过如下方式来申明一个转换函数:

operator double() const { 
    return (double) (m_numerator / m_denominator);
}
如果我们进行如下的调用:
Fraction f(3,5);
Fraction d2 = f + 4;
编译器会首先检查第二行的表达式中 f 的类型。当发现 f 的类型和 4 不匹配时,编译器检查是否有符合 f 的类型转换规则。因此,这里 f 通过定义被转化成了 0.6,从而可以和 4 进行相加。

几点需要注意的是:

  1. 在函数名前面不能指定函数类型,函数没有参数。
  2. 该函数返回值的类型是函数声明中指定的类型。
  3. 类型转换函数只能作为成员函数,不能作为友元函数或普通函数。因为因为被转换是本类对象。

转换构造函数

转换函数在实质上是使得对象能转化成其他类型的数据。我们可以从另外一个方面来考虑一下:如果我们转换表达式里其他的元素来适配类对象的类型,也可以达到同样的效果。C++ 中的,我们可以通过转换构造函数(Converting Constructor)来实现这一点。

转换构造函数 的作用是将一个其他类型的数据转换成一个类的对象。当一个构造函数只有一个参数,而且该参数又不是本类的 const 引用时,这种构造函数称为转换构造函数。

Fraction(int num, int den = 1)
 : m_numerator(num), m_denominator(den) { }

如果我们进行同样的调用:

Fraction d2 = f + 4.0;

此时,编译器发现了 + 号的存在,于是去查看我们是如何重载 + 的,然后根据 + 需要的算子类型,尝试用我们先前定义的构造函数去转换这个算子。我们定义的构造函数是将 n 转化为 (n, 1),因此对 4 也能进行同样的操作。

Fraction d2 = f + Fraction(4, 1);
当然,为了完成这个加法,我们需要重载类对象之间的 + 运算:
Fraction operator + (const Fraction& f) {
    return .....;
}
需要注意的是,这里的构造函数有两个 parameter,但提供一个 argument 就足够了;因此这样的构造函数也被称为 Non-explicit-on-argument Constructor

Explicit 关键字

explicit 指定符指定构造函数或转换函数不允许隐式转换,也就是告诉编译器:本函数拒绝将其他类型的数据转化为类对象。

先前给出了转换类型的两种方法。假设我们将这两种方法放置在了一个类里,那么编译器也分不出来优先使用谁,也就是所谓的二义性(Ambiguous)。

但如果我们加上 explicit,则会出现 “Conversion from 'double' to 'Fraction' requested” 的错误。原因就是构造函数拒绝将 4 转化为 (4,1)

注:代码会不会产生二义性是取决于我们如何设计这些方法。

功能类的类别与实现

有时候 C++ 提供的 build-in 类型和功能并不能完全的满足我们的要求。我们通常通过设计一些功能型类来加强 build-in 类型和功能。显然的是,不管添加什么新功能,我们设计的这些功能类都必须包含以前 build-in 类型所具有的基本功能。

C++ 功能型类的类型往往分成两种:

  • Pointer like class,即类对象功能类似于指针的类
  • Function like class,即类对象功能类似于函数的类

Pointer-like class

这种类型的类包含了指针的基本功能。C++ 中有两个非常著名的例子:智能指针(Smart Pointer)、迭代器(Iterator)。

为了实现这些类,我们需要在这些类中实现指针的基本操作 :* (取值),(调用)。

智能指针

要在智能指针中实现指针的基本功能,我们可以来这么做:

首先,通过一个构造函数来创造一个指针,指针的默认值就是一个基础的指针:

shared_ptr(T* p): px(p) {} //px is a space for storing pointer
而这个基础指针是通过类对象的创建得到的:
shard_ptr<Type> sp(new Type);

接下来,我们开始实现指针操作:
T& operator * () const { return *px; } //dereference
T* operator -> () const { return px; } // return px
调用的方法如下:
Type f(*sp); 
sp->func(); // using pointer up to call function func()
需要注意的是: 操作符会一直对得到的结果作用下去,直到得到最终的结果。对于 在智能指针的实现,sp→ 转换后得到的是 px。由于 的特性,sp→func() 实际上可以写成 px(→)func(),也就是 px→func()

迭代器

迭代器的功能是指向容器中的元素,因此迭代器实际上的功能也包含指针的功能。带迭代器除了实现基本的功能之外,还需要做一些移位、比较的功能,因此迭代器比起基本指针还需要设计自增/减等功能。

而迭代器也需要对对象进行操作,因此和智能指针的设计有一些区别。以链表作为例子,因为链表的指针信息是存储在节点中的,所以对指针基本操作的实现如下:

T& operator * () const { return (*node).data; } // returned a reference
T* operator -> () const { return  &( operator * ()); } // returned an address
这里重载 的写法实际上满足了使用端的意图:
Foo::function();
(*iter).function();
(&(*iter)) -> function(); //pointer needed here
的实现通过嵌套 * 的实现完成了功能。

Function-like class

类的功能也可以被设计为像一个函数一样工作。我们都知道函数的结构是 name + function call operator ”()“,因此这种类实际上是通过重载 () 来实现类似函数的功能。比如我需要实现一个 pair 类的操作功能(pair 包含两个不同类型数据):

/* read the first element*/
struct select1st ... {
    const typename Pair::first_type& operator ()
          (const Pair& x) const
          { return x.first);
};
上面的对 () 进行了重载:接收一个 pair 对象,然后返回该对象的第一个元素。这个类的对象,实际上具有了函数的功能。我们可以把这样的类称为仿函数

上面的代码中中间有一段 ,这实际上是这些函数需要继承的父类。而这些父类一边可以表示为:
unary_function;
binary_function;
这些父类实际上表示了类的参数个数。这些奇特的父类看上去大小是 0,但实际测试上很可能为 1

用Namespace测试函数

namespace 的主要作用就是防止函数或者类的名称冲突。利用这个特性,我们可以用 namespace 来做测试。比如我们要测试 20 个函数,那么我们就可以把每一个函数放到单独的一个 namespace 里:

namespace test01 {
    void test_function_01() {
        ....
    }
}
namespace test02 {
    void test_function_02() {
        ....
    }
}
而在测试中我们就可以通过命名空间单独测试每一个函数了:
test01::test_function_01();
test02::test_function_02();
这样做的好处在于,每一个测试函数都是完全独立的,包括全局变量,我们也可以用 namespace 来模拟。通过这种方法,我们可以最大限度的避免函数之间共享变量带来的测试上的误差。

模板

函数模板和类模板的定义和使用请参考:面向对象(上)第二周笔记3.3

有一点需要注意的是,在函数的 argument deduction 的过程中,编译器也支持对自定义类型的对象的推测。模板本身可以编译,但使用的时候会根据具体的内容再编译一次,而这个过程中需要增加额外的验证(比如是否对自定义类型进行了相应的运算重载);而这个过程很可能导致编译失败。

成员模板

我们来考虑一下这样的情况:如果我们有四个类,有如下的关系:

<html>

<img src=”/_media/programming/cpp/boolan_cpp/week_4_class.svg“ width=“500”/>

</html>

用先前的 pair 类做为范例,我们可能会有这样的组合:

  • pair<fish, bird>
  • pair<catfish, craw>

catfish 类和 craw 类 分别属于 fish 类 和 bird 类的子类,将他们的类对象作为相应父类的初始化值肯定是合情合理的;但语法上的实现有一点问题:pair 类类型接收 fish & bird 类类型以后,该类的构造函数只能接受 fish 类 和 bird 类作为初始化参数。我们要怎么让 pair 接受 catfish 类和 craw 类作为它构造函数的初始化类型?

为了解决这个问题,我们需要用到成员模板。成员模板允许我们在类模板中使用新的模板类型,从而使得类中被模板化的类型可以改变为新类型。

就上述的问题来说,通过父类的构造函数,接收相应的子类作为初始值的方法,可以表现为如下代码:

temeplate <class U1, class U2>
pair(const pair<U1, U2>& p)
    :first(p.first), second(p.second) {}
成员模板在类模板中会表现为函数模板的形式,因此这里的 U1U2 则是两个对应的子类的类型。当我们调用该构造函数的时候,参数是子类,但我们的 pair 对象仍然可以建立。

除此之外,类模板也可以应用到指针类上。对于子类,我们一般可以通过 upcast ,即父类的对象可以是子类的方法来创建一个新类对象:
Animal ptr = new pig; // a pointer to a animal. this animal could be a pig
智能指针作为一种类,因此也需要通过成员模板来实现接收参数的变更:

template<typename Tp1>
explicit shared_ptr(Tp1* pointer): base_shared_ptr<Tp>(pointer) {}
在这里,base_shared_ptr 作为父类的指针,接收的是 Tp 类型的参数,而我们通过成员模板定义的类型,成功的将子类交给了 base_shared_ptr 的构造函数进行初始化。如果要实现与上述普通指针一样的功能,具体的调用可以写成这样:
shard_ptr<Animal> spter(new pig);

模板的特化

特化Specialization)相对于泛化(比如模板)而言。它表达了一种概念:对于某一些指定的类型,我们可以采用相应的处理方式来对其进行操作。这样的处理往往很高效,但我们需要制定规则来让编译器知道应该怎么做。而 C++ 的特化则很好的解决了这个问题。

全特化(Full Specialization)的模板参数列表应当是空的,并且应当给出模板的 “实参” 列表(函数模板不需要,因为也可以通过 argument deduction 推导出类型:

template<>
struct hash<int> {...};
struct hash<char> {...};
.....
应用则可以写成如下代码;对应不同的类型,编译器会指定不同的类:
cout << hash<int> () (1000);
cout << hash<char> () ('a');

模板的偏特化

模板的偏特化(Partial Specialization)是相对于全特化来说的,指只对模板参数中的一部分内容进行特化。偏特化分为两种:

  • 对模版参数列表中的某一个参数进行特化(个数上的偏特化)
  • 对模板参数类型选择范围的缩小(范围上的偏特化)


第一种偏特化的例程如下:

template <class T2>
class A<int, T2> {
    ...
};
A 的模板接受两个参数,一个指定为 int 类型的参数,另外一个参数则可以为任意类型。

第二种片特化的例程如下:
template <typename T>
class B {
    ....
};
//partial specialization from T to T*
template <typename T>
class B<T*> {
    ....
};
上面模板的参数从接收任意的类型,通过偏特化变成了接受指向任意参数类型的指针。

模板作为模板的参数

模板的参数自己本身也可以是模板。一个比较常见的例子就是我们的模板参数需要用到标准库的容器(标准库中的很多容器都是模板类)。模板做参数的写法如下:

temeplate<tpyename T, 
          template <typename T>
              class Container
         >
Class A {
    private: Container<T> c;
......
};
如果作为参数的模板只需要一个参数,那么我们可以写成如下格式:
/*using smart pointer as a template parameter*/
A<string, shard_ptr> p1;
要注意的是:如果作为参数的模板需要的参数不止一个,且没有提供默认值,我们就不能直接像下面这么写:
A<string, list> myList1; // error, becuase list need a allocator<T> parameter。
我们我们可以用 type alias 来写:
template<typename T>
using Lst  = list<T, allocator<T>>;
A<string, Lst> myLIst2; // ok
还必须提到的是,如果在使用的过程中,作为参数的模板已经被指定了类型,那么该参数不能被称为模板模板参数:
stack<int, list<int>> s2; //list<int> is not a template template parameter

标准库简介

标准库是 C++ 的另一个重要的部分。标准库包含了容器(Container),算法(Algorithms),迭代器(Iterators),仿函数(Functors)等内容:

  • 容器代表了数据结构
  • 算法代表了我们学习的算法
  • 迭代器是对容器操作的指针
  • 仿函数是实现了函数功能的类


关于学习方法:学习库这样的内容,最好的办法是把里面的方法都自己用一遍。

C++11的额外内容

C++ 11(2.0) 提供了很多新内容。这些新内容需要新版本的编译器支持:

  • CppRocks 可以查看你的编译器是否支持 C++11。
  • 如果 IDE 是 Dev C++, 可以通过 Project OptionsComplierCode GenerationLanguage standard 设置编译版本为 ISO C++11
  • 可以通过输出 __cplusplus 宏来确定当前 C++ 版本;该宏按 C++ 标准被要求必须集成到编译器里面。

参数可变的模板

传统C++中的模板必须指定参数个数。C++11 给出了模板的一个新内容:我们可以给模板指定动态数量的参数;我们称这样的模板为参数可变的模板Variadic Template)。

Variadic Template 声明的形式为 First + Rest(Pack) 的形式,也就是第一个元素为一部分,剩余的其他元素作为一整体为另一部分:

template<typename T, typename... Types>
void print(const &T, const Types&...args) {
    cout << firstArg << endl;
    print(args...);
}
void print() {} //base case

我们用 在这里表示剩余的元素是一个整体。而 print() 函数则用于递归打印每一个参数的值。

如果我们这么调用:

print(7.5, "hello", bitset<16>(377), 42);
打印结果为:
7.5
hello
00000000101111001
42
sizeof… 函数用于求 Rest Pack 中的元素。

Auto

auto 指定符是 C++11中的一个语法糖。该指定符可以让编译器自动推断变量类型。关于 auto 的详细内容请查看:Auto的基础信息

需要注意的是,使用 auto 并不代表依赖 auto。使用 auto 的前提是你必须清楚的知道自己需要定义什么类型的变量。auto 只能帮你简化输入,而并不能帮你做其他事情。

Rang Base For

C++11 中提出了 for 循环的的一种新的写法:Range for。这种循环的方式依赖于迭代器,主要使用对象是标准库里的容器。这种循环的写法如下:

for (declaration : expression)
     statments;
expression 部分必须是一个序列:比如:列表初始化的序列、数组、string、vector 等等。

range for 通过迭代器操作元素。如果想修改元素,我们需要用引用;
for (auto &ele : container)
    ele *= 3 ;
引用虽然在调用函数的时候也开辟了栈空间存储自身,但存储的是 argument 的地址;所有对这个变量的使用都是通过指针在操作 argument。因此,我们在函数内修改了引用的时候,也就修改了 arugment 的值。

Reference

从内存的角度上来看,变量的种类有三种:值,指针,引用。指针是一种地址的形式,表达指向该地址的值;引用可以看作是一种和值绑定的别名,换句话说,引用代表了值。因此,指针的大小是一个字长,而引用的大小则是它对应的值是大小。

引用从实现手段上是通过指针实现的,但从逻辑上,设计者希望将引用表达成一种代表的概念。因此,引用的大小和被其代表的对象大小相同,而两者的地址也是相同的。因此,当描述函数参数的时候,对于值传递和引用传递,他们具有 Same Signature;也就是说,如果有如下声明:

double imag(const double& im);
double imag(const double im);
当我们调用 imag 函数的时候,会引起二义性。
imag(4.0); // Ambiguity
不过,const 也算是 signature 的一部分。因此如下定义不会被判定为二义性:
double imag(const double& im) {....};
double imag(const double im) const {....};
需要注意的几点:

  • 引用必须初始化
  • 引用通常主要用于参数类型和返回类型的描述,而不用于声明变量。
引用绑定后不可更改

指针指向对象以后还能修改,引用一旦绑定对象,就不能再次更改了,为什么呢?
从编译的角度来说,程序在编译时分别将指针和引用添加到符号表上,符号表上记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值。符号表生成后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。