本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!
C++ 中可以对 build-in 类型的数据进行自动转换。但当我们希望对自定义的类型进行自动转化的时候,我们就需要自定义一个规则来匹配类型。我们称这个匹配类型的方法为转换函数(Conversion Function)。
转换函数可以通过两种不同的路径来实现:
第一种方式是通过类型转化函数中改变对象的类型来实现的;而第二种方式则是通过构造函数来实现。
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
进行相加。
转换函数在实质上是使得对象能转化成其他类型的数据。我们可以从另外一个方面来考虑一下:如果我们转换表达式里其他的元素来适配类对象的类型,也可以达到同样的效果。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
指定符指定构造函数或转换函数不允许隐式转换,也就是告诉编译器:本函数拒绝将其他类型的数据转化为类对象。
先前给出了转换类型的两种方法。假设我们将这两种方法放置在了一个类里,那么编译器也分不出来优先使用谁,也就是所谓的二义性(Ambiguous)。
但如果我们加上 explicit
,则会出现 “Conversion from 'double' to 'Fraction' requested” 的错误。原因就是构造函数拒绝将 4
转化为 (4,1)
。
注:代码会不会产生二义性是取决于我们如何设计这些方法。
有时候 C++ 提供的 build-in 类型和功能并不能完全的满足我们的要求。我们通常通过设计一些功能型类来加强 build-in 类型和功能。显然的是,不管添加什么新功能,我们设计的这些功能类都必须包含以前 build-in 类型所具有的基本功能。
C++ 功能型类的类型往往分成两种:
这种类型的类包含了指针的基本功能。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
而 →
的实现通过嵌套 *
的实现完成了功能。
类的功能也可以被设计为像一个函数一样工作。我们都知道函数的结构是 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
来做测试。比如我们要测试 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) {}
成员模板在类模板中会表现为函数模板的形式,因此这里的 U1
、U2
则是两个对应的子类的类型。当我们调用该构造函数的时候,参数是子类,但我们的 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(2.0) 提供了很多新内容。这些新内容需要新版本的编译器支持:
Project Options
→ Complier
→ Code Generation
→ Language 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
指定符是 C++11中的一个语法糖。该指定符可以让编译器自动推断变量类型。关于 auto
的详细内容请查看:Auto的基础信息。
需要注意的是,使用 auto
并不代表依赖 auto
。使用 auto
的前提是你必须清楚的知道自己需要定义什么类型的变量。auto
只能帮你简化输入,而并不能帮你做其他事情。
C++11 中提出了 for 循环的的一种新的写法:Range for。这种循环的方式依赖于迭代器,主要使用对象是标准库里的容器。这种循环的写法如下:
for (declaration : expression)
statments;
expression
部分必须是一个序列:比如:列表初始化的序列、数组、string、vector 等等。
for (auto &ele : container)
ele *= 3 ;
引用虽然在调用函数的时候也开辟了栈空间存储自身,但存储的是 argument 的地址;所有对这个变量的使用都是通过指针在操作 argument。因此,我们在函数内修改了引用的时候,也就修改了 arugment 的值。
从内存的角度上来看,变量的种类有三种:值,指针,引用。指针是一种地址的形式,表达指向该地址的值;引用可以看作是一种和值绑定的别名,换句话说,引用代表了值。因此,指针的大小是一个字长,而引用的大小则是它对应的值是大小。
引用从实现手段上是通过指针实现的,但从逻辑上,设计者希望将引用表达成一种代表的概念。因此,引用的大小和被其代表的对象大小相同,而两者的地址也是相同的。因此,当描述函数参数的时候,对于值传递和引用传递,他们具有 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 {....};
需要注意的几点:
指针指向对象以后还能修改,引用一旦绑定对象,就不能再次更改了,为什么呢?
从编译的角度来说,程序在编译时分别将指针和引用添加到符号表上,符号表上记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值。符号表生成后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。