《Effective C++》 总结笔记
1. 让自己习惯C++1:视C++为一个语言联邦c++是多重范型编程语言,视c++包括4种次语言: 1:C;2:Object-Oreinted C++;3:Template C++;4:STL(template程序库,包括容器、迭代器、算法和函数对象)。2:尽量以const,enum,inline替换 #defineconst:#define直接替换导致名称从未被编译...
1. 让自己习惯C++
1:视C++为一个语言联邦
- c++是多重范型编程语言,视c++包括4种次语言: 1:C; 2:Object-Oreinted C++;3:Template C++;4:STL(template程序库,包括容器、迭代器、算法和函数对象)。
2:尽量以const,enum,inline替换 #define
- const:
- #define直接替换导致名称从未被编译器看到
- const定义常量也可能比#define导致较小量的码
- #define不重视作用域,故不提供封装性
- enum:
- 取一个const的地址是合法的,但取一个enum的地址不合法
- inline:
- #define定义函数可能招致误用,最好用inline函数替换
记:
- 对于单纯常量,最好以const对象或enums替换#defines;对于形似函数的宏,最好改用inline函数替换#defines。
3:尽可能使用const
- 令函数返回一个常量值,可以预防无意义的赋值动作(例:p19)
const成员函数:
1.const对象只能访问const成员函数,而非const对象可以访问任意的成员函数
2.const成员函数不能修改对象的数据成员,const对象的成员变量不可以修改(mutable修饰的数据成员除外)注:
- 两个成员函数如果只是常量性不同,是可以被重载的
- 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本避免代码重复(使用转型,条款27提及)
4:确认对象被使用前已先被初始化
- 为内置型对象进行手工初始化;内置类型以外,构造函数负责初始化责任
- 构造函数最好使用成员初值列 ,而不使用赋值操作 ;最好总是以声明次序为其次序
- 不同编译单元的non-local static对象初始化相对次序并无明确定义,以local对象替换得以免除问题,例:
class Example { ... };
Example& emp()
{
static Example emp;
return emp;
}
注:
- 函数内的local static对象会在“该函数调用期间”“首次遇上该对象之定义式”时被初始化
2.构造/析构/赋值运算
5:了解C++默默编写并调用了哪些函数
- 如果自己不声明, 编译器就会暗自为class创建一个default构造函数、一个copy构造函数、一个copy assignment操作符(代码合法有意义时编译器才会生成),以及一个析构函数
注:
- base class如果把copy构造函数或copy assignment操作符设置为private,derived class将拒绝生成copy构造函数或copy assignment操作符
6:若不想使用编译器自动生成的函数,就该明确拒绝
- 将默认生成的成员函数声明为private而且故意不实现他们,或者使用像Uncopyable这样的base class也是一种做法
7:为多态基类声明virtual析构函数
- polymorphic(带多态性质的)base classes才应该声明一个virtual析构函数(免除“局部销毁”问题),不为多态用途的base classes不该声明virtual 析构函数
8:别让异常逃离析构函数
- 析构函数绝对不能抛出异常;如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作
注:
如果析构函数抛出异常,可能导致两个异常同时存在,程序若不是结束执行就是导致不明确行为
9:绝不在构造和析构过程中调用virtual函数
- 由于无法使用virtual函数从base classes向下调用,可以令derived classes将必要的构造信息向上传递至base class构造函数替换
注:
base class构造期间virtual函数绝不会下降到derived classes阶层,原因有二:
1. base class构造或析构函数执行时derived class的成员变量尚未初始化,如果调用的virtual函数下降到derived class阶层,必定导致使用的成员变量未初始化
2. 在derived class的base class构造期间,对象的类型是base class,不会成为一个derived class对象
10:令operator= 返回一个reference to *this
- 为了实现“连锁赋值”等
11:在operator= 中处理“自我赋值”
- 加一个“证同测试”使具有“自我赋值安全性”
- 精心安排的语句可以使代码具有“异常安全性”(自动获得“自我赋值安全性”):在复制构造之前别删除原指针
另一种替代方案是“copy and swap”技术
注:
- 将copying动作从函数本体移至函数参数构造阶段牺牲了清晰性,有时却可生成更高效的代码
12:复制对象时务忘其每一个成分
- 复制所有的local成员变量以及所有base class成分
- 不要尝试以一个copying函数实现另一个copying函数。应将共同机能放进第三个函数中并由它们共同调用
3.资源管理
13:以对象管理资源
- 为了防止资源泄漏,请使用RAII对象,在构造函数里面获得资源,并在析构函数里面释放资源
- auto_ptr:被销毁会自动删除它所指之物,复制所得的指针将获得资源的唯一拥有权
- 引用计数型智慧指针(RCSP):持续追踪多少个指针指向该资源,无人指向他时自动删除该资源
14:在资源管理类小心copy行为
- 一般资源管理类复制时可以选择以下做法:
- 禁止复制(复制不合理)
- “引用计数法”(使用tr1::shared_ptr指定“删除器”阻止引用次数为0时的删除行为)
- 复制底层资源(“深度拷贝”)
- 转移底部资源的拥有权(auto_ptr)
15:在资源管理类中提供对原始资源的访问
- get成员函数实现显式转换(安全,受欢迎)
- 隐式转换函数(方便)例:
class Tmp{
public:
...
operator TmpConvertType() const //隐式转换函数
{ return t; }
...
private:
TmpConvertType t;
};
16:成对使用new和delete要采用相同的格式
- new和delete对应,new[ ]和delete[ ]对应
17:以独立的语句将newd对象置入智能指针
注:
- 在一个语句中编译器拥有重新排列操作的自由,如此一来可能被异常干扰,发生资源泄露
4.设计与声明
18:让接口容易被正确使用,不易被误用
- 好的接口很容易被正确使用,不容易被误用;努力达成这些性质
- “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容;“防治误用”的办法包括建立新类型,限制类型上的操作,束缚对象值,以及消除用户的资源管理责任
- tr1::shared_ptr支持定制型删除器,可预防DLL问题,可被用来自动解除互斥锁等等
19:设计class犹如设计type
- 好的types有自然的语法,直观的语义,以及一或多个高效实现品,设计时考虑所面对的问题
20:宁以pass-by-refrence-to-const替换pass-by-value
- 尽量以pass-by-reference-to-const替换pass-by-value,比较高效,并可避免切割问题
- 对于内置类型,以及STL的迭代器和函数对象pass-by-value往往更高效
21:必须返回对象时,别妄想返回其reference
- 绝不要返回pointer或reference指向一个local stack对象(在函数退出前被销毁)
- 不要返回pointer或reference指向一个heap对象(用户不知道如何delete)
- 不要返回pointer或者reference指向local static对象而有可能需要多个这样的对象(同一行不能调用多次该函数,static只有一份)
22:将成员变量申明为private
- 切记将成员变量申明为private,这可具有语法的一致性、更精确的访问控制、封装、提供class作者充分的实现弹性等优点
- protected并不比public更有封装性
23:宁以non-member,non-friend替换member
- 愈多函数可访问它,数据的封装性就愈低,故member函数封装性差
- 将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数,降低了编译依存性,这正是STL的做法
24:若所有参数都需要类型转换,请为此采用non-member函数
注:
- 只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者;this对象(隐喻参数)绝不是隐式类型转换的合格参与者
- menber函数的反面是non-member函数,不是friend函数
25:考虑写一个不抛出异常的swap函数
- 当std::swap对自定义类型效率不高时(例如深拷贝),提供一个swap成员函数,并确定不会抛出异常
如果提供一个member swap,也该提供一个non-member swap用来调用前者 (对class而言,需特化std::swap;对class template而言,添加一个重载模板到非std命名空间内)
注:
- 不可以添加新的东西到std内
- 调用swap时应该针对std::swap使用using声明式,然后调用swap不带任何”命名空间修饰”
5.实现
26:尽可能延后变量定义式出现的时间
- 不只应该延后变量定义直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止,这样可增加程序的清晰度并改善程序效率
27:尽量少做转型动作
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast;试着发展无需转型的替代设计
- 如果转型是必要的,试着将它隐藏于某个函数后
- 宁可使用C++-style转型,不要使用旧式转型(新式转型很容易辨识出来,而分门别类)
注:
- 四种新式转型如下:
- static_cast:适用范围最广的,适用于很多隐式转换,基本数据类型的转换,基类指针与子类指针的相互转换,或者添加const属性,任何类型转换为void类型
- dynamic_cast:主要用来执行“安全向下转型”,决定某对象是否归属继承体系中的某个类型。static_cast在下行转换时不安全,是因为即使转换失败,它也不返回NULL ,而dynamic_cast转换失败会返回NULL;对于上行转换,dynamic_cast和static_cast是一样的
- const_cast:通常用来将对象的常量性消除
- reinterpret_cast:在比特位级别上进行转换。它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针,不能将非32bit的实例转成指针。最普通的用途就是在函数指针类型之间进行转换,不可移植
28:避免返回handles指向对象内部成分
- 避免返回handles(包括references、指针、迭代器)指向对象内部(包括成员变量和不被公开的成员函数),否则会破坏封装性,使const成员函数的行为矛盾,以及发生“空悬虚吊号牌码”
29:为“异常安全”而努力是值得的
- “异常安全函数”即使发生异常也不会有泄漏资源或允许任何数据结构败坏,区分为以下三种保证:
- 基本承诺:异常抛出,程序内的任何事物仍然保持在有效状态下
- 强烈保证:异常抛出,程序状态不改变,回复到调用函数之前的状态(往往能够以copy-and-swap实现出来)
- 不抛掷保证:绝不抛出异常(如内置类型)
- 可能的话提供“nothrow保证”,当“强烈保证”不切实际时,就必须提供“基本保证”
- 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者
30:透彻了解inline函数的里里外外
- 将大多数inlining限制在小型、被频繁调用的函数身上
- Template的具现化与inlining无关(Template放在头文件只是因为一般在编译器完成具现化动作)
注:
- inline只是给编译器的建议,大部分的编译器拒绝将太过复杂的函数inlining,隐喻方式是将函数定义于class定义式内
- 构造函数和析构函数往往是inlining的糟糕候选人
- 随着程序库的升级,inline函数需要重新编译,而non-inline函数只需重新连接
31:将文件的编译依存关系降到最低
- 支持”编译依存最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes
- Handle classes:在.h文件中用class 声明代替include头文件,把成员变量替换为指针的形式,理解的实现方式大致为:
// Person.h
#include <string>
using namespace std;
class PersonImp;
class Date;
class Address;
class Person
{
public:
Person(const std::string& name,const Date& birthday,const Address& addr);
string Name() const;
string Birthday() const;
string Address() const;
private:
//string Name; 之前的定义方式,并且以include头文件实现
//Date Birthday;
//Address Address;
std::tr1::shared_ptr<PersonImp> pImpl;
//通过提供的PersonImp接口类指针替换实现Person,起到了隔离的作用
// Person.cpp
#include "Person.h" //正在实现Person类
#include "PersonImpl.h" //使用PersonImp接口类实现Person
//类,必须使用其成员函数,所以要
//include接口类头文件
Person::Person(const std::string& name,const Date& birthday,const Address& addr)
:pImpl(new PersonImpl(name,birthday,addr))
{ }
string Person::Name() const
{
return pImpl->Name();
}
... //其余函数实现
// PersonImp.h
#include <string>
#include "MyAddress.h"
#include "MyDate.h"
using namespace std;
class PersonImp //充当一个接口类,成员函数和Person相同,供
//Person类通过指针调用
{
public:
string Name() const
{
return Name;
}
... //其余成员函数定义
private:
string Name; //放置了所需的外来类对象
MyAddress Address;
MyDate Birthday;
};
- 总之,此时任何接口类头文件产生的变化只会导致接口类头文件的变化而重新编译,以及Person实现文件由于include了接口类的头文件也要重新编译;而Person类头文件由于只使用了类的声明式,所以并不会重新编译,因此所有使用Person类的对象的文件也都不需要重新编译了,这样就大大降低了文件之间的编译依存关系
- 另外,用Interface Classes也可以降低编译的依赖,实现方法大致是父类只提供虚方法,而将实现放置在子类中,再通过父类提供的一个特别的静态函数,生成子类对象,通过父类指针来进行操作;从而子类头文件的改动也不会导致使用该类的文件重新编译,因为用的是父类指针,客户include的是只是父类头文件,该静态方法实现如下:
std::tr1::shared_ptr<Person> Person::Create(const std::string& name,
const Date& birthday,
const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
注:
- 对于C++类而言,如果它的头文件变了,那么所有这个类的对象所在的文件都要重编,但如果它的实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编
- 编译依存最小化的设计策略:
1、如果使用object references或object pointers可以完成任务,就不要用objects
2、如果能够,以class声明式替换class定义式
3、为声明式和定义式提供不同的头文件
6.继承与面对对象设计
32:确定你的public继承塑模出is-a模型
- public继承意味着is-a。适用于base class身上的每一件事情也一定适用于derived class身上
33:避免遮掩继承而来的名称
- 编译器对于各作用域有查找顺序,所以会造成名称遮掩,各作用域依次为:
- global作用域
- namespace
- base class
- derived class
- local作用域
- 等等
- 可以利用using声明式或者inline转交函数使遮掩函数重见天日
34:区分接口继承和实现继承
- pure virtual函数使derived class只继承函数接口
- impure virtual函数使derived class继承函数接口和缺省实现
- non-virtual函数使derived class继承函数的接口和一份强制性实现
35:考虑virtual函数以外的其他选择
- Non-Virtual Interface手法实现Template Method模式:令客户通过public non-virtual成员函数间接调用private virtual函数,得以在一个virtual函数被调用之前设定好场景,并在调用结束之后清理场景
- 藉由Function Pointers实现Strategy模式:可以由构造函数接受一个指针,指向一个提供的函数,例如:
class GameCharacter; // 前置声明
int defaultHealthCalc(const GameCharacter& gc); //缺省函数
class GameChaaracter{
public:
typedef int(*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf=defaultHealthCalc)
:healthFunc(hcf)
{}
int healthValue()const
{ return healthFunc(*this); }
……
private:
HealthCalcFunc healthFunc; //函数指针
};
- 藉由tr1::function完成Strategy模式:改用一个类型为tr1::function的对象,这样的对象可以保存任何可调用物(callable entity,即函数指针、函数对象、成员函数指针),只要其签名式兼容于需求端,typedef语句修改为:
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
- 古典的Strategy模式:将继承体系内的virtual函数替换为另一个继承体系内的virtual函数,如下:
class GameCharacter;//forward declaration
class HealthCalcFunc{
public:
……
virtual int calc(const GameCharacter& gc) const
{ …… }
……
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter{
public:
explicit GameCharacter(HealthCalcFunc* phcf=&defaultHealthCalc)
:pHealthCalc(phcf)
{}
int healthValue() const
{ return pHealthClac->calc(*this); }
……
private:
HealthCalcFunc* pHealthCalc;
};
36:绝不重新定义继承而来的non-virtual函数
注:
- non-virtual函数是静态绑定的,virtual函数是动态绑定的
37:绝不重新定义继承而来的缺省参数值
- 如拒绝这样做,可能会在调用一个定义于derived class内的virtual函数时,
使用base class指定的缺省参数值 - 使用NVI手法(令public non-virtual函数调用private virtual函数)可以防止缺省参数值被重新定义
注:
- 为了运行期效率,c++坚持缺省参数值为静态绑定,防止运行期复杂的决定
38:通过复合塑模出has-a或者”根据某物实现出”
- 当复合发生于应用域内的对象之间,表现has-a的关系;当它发生于实现域内则是表现is-implemented-in-terms-of的关系
- 复合的意义和public继承完全不同
39:明智而审慎地使用private继承
- Private继承意味implemented-in-terms-of(只有实现被继承,接口部分应略去)
- 尽可能使用复合,必要时才使用private继承(当derived class想访问base class的protected成分时,或为了重新定义virtual函数时,还有造成EBO(empty base optimization)节省内存时才为必要)
注:
- private继承规则如下:
- 编译器不会自动将一个derived class对象转换为一个base class对象,不是is-a
- 所有成员都会变成private属性
40:明智而审慎地使用多重继承
- 多重继承可能从多个base class继承相同名称,也可能导致要命的“钻石型多重继承”(base class被多次构造,可以使用virtual继承解决)
- 使用virtual继承导致对象体积大,访问成员变量速度慢等问题;因此,非必要不要使用virtual bases,如果要使用,尽可能避免在其中放置数据(相当于对virtual继承)
- 多重继承的一个正当用途是“复合+继承”技术,单一继承更受欢迎
7.模板与泛型编程
41:了解隐式接口和编译期多态
- classe和template都支持接口和多态
- 对class而言接口是显式的,由函数签名式构成;多态是通过virtual函数发生于运行期
- 对template而言接口是隐式的,由有效表达式组成;多态是通过template具现化和函数重载解析发生于编译期
42:了解typename的双重意义
- 声明template参数时,前缀关键字class和typename可以互换
- 使用typename标识嵌套从属类型名称(如果编译器在template中遭遇一个嵌套从属名称,它便假设这名称不是个类型),但是不得在base class lists或member initialization list内作为base class修饰符
43:学习处理模板化基类内的名称
- template特化版本可能不提供和一般性template相同的接口,所以从Object Oriented C++跨进Template C++时,继承就不像以前那般畅行无阻了
- 为了令c++不进入templatized base classes观察的行为失效,可以:
- 在调用动作之前加上“this->”
- 使用using声明式(using baseclass::func;)
- 明白指出被调用的函数位于base class内(baseclass::func())
44:将参数无关代码抽离template
- 非类型模板参数造成的代码膨胀,以函数参数或者class成员变量替换template参数
- 类型模板参数造成的代码膨胀,可以让具有完全相同二进制表述的具现类型共享实现码
45:运用成员函数模版接收所有兼容类型
- 请使用成员函数模版生成“可接受所有兼容类型”的函数
temmplate<typename T>
class SmartPtr{
public:
template<typename U>
SmartPtr(const SmartPtr<U>& other) //泛化copy构造函数
: heldPtr(other.get()){...} //只有当“存在某个隐式转换可将U*
//转换为T*”时才能通过编译,从而
//约束转换行为
T* get() const {return heldPtr;}
...
private:
T* heldPtr; //持有的内置指针
};
- 即使声明了“泛化拷贝构造函数”和“泛化的赋值操作符”,仍然需要声明正常的拷贝构造函数和拷贝赋值操作符
46:需要类型转换时请为模版定义非成员函数
- 当我们编写一个class template,而它所提供之“与此template相关的”函数支持“所有参数之隐式类型转换”时,请将那些函数定义为“class template内部的friend函数”,例如:
template <typename T>
class Rational{
public:
…
friend const Rational<T> operator* (const Rational<T>& lhs,
const Rational<T>& rhs)
{ //定义体
return Rational (lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
…
}
- 在一个class template内,template名称可被用来作为“template”的简略表达方式
注:
- template实参推导过程中从不将隐式类型转换函数纳入考虑,而class template并不依赖template实参推导,在生成模板类时就可推导出函数而非函数模板
47:请使用traits classes表现类型信息
- Traits classes 使得“类型相关信息”在编译期可用。它们以 templates 和 “templates 特化”完成实现,如下:
template<...> //自定义迭代器
class deque{
public:
class iterator{
public:
//嵌套的typedef迭代器分类
typedef random_access_iterator_tag iterator_category;
};
};
template<typename IterT>
struct iterator_traits{
//用iterator_category表现IterT的类型
typedef typename IterT::iterator_category iterator_category;
...
};
注:
- iterator_traits通过特化版本可以提供希望支持的相关类型(如指针类型)
- 上述iterator_traits::iterator_category在编译期确定,而if判定却是运行期
确定,不仅浪费时间,也造成可执行文件膨胀
整合重载技术后。traits classes 有可能在编译期对类型执行 if…else 测试
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d, std::random_access_iterator_tag)
//这份实现用于random access迭代器
{
iter += d;
}
... //用于其它迭代器的实现
template<typename IterT, typename DistT> //用函数模板获控制函数调用
void advance(IterT &iter, DistT d) //上述函数并传递类型信息
{
doAdvance(iter, d,
typename std::iterator_traits<T>::iterator_category());
}
48:认识模板元编程
- 模板元编程可将工作由运行期移至编译期,因而得以实现早期错误侦测和更高的执行效率,可能导致较小的可执行文件,较短的运行期,较少的内存需求,可以解决不少问题
8.定制new和delete
49:了解new-handler的行为
- 当operator new无法满足某一内存分配需求时,它会抛出异常;抛出异常之前,也可以先调用一个客户指定的错误处理函数(new-handler),调用set_new_handler可以指定该函数
- Nothrow(在无法分配足够内存时返回NULL)是一个颇为局限的工具,它只适用于内存分配,后继的构造函数调用还是可能抛出异常
50:了解new和delete的合理替换时机
- 有许多理由需要写个自定的new和delete,包括检测错误、改善效能,以及收集使用上的统计数据等等
51:编写符合常规的new和delete
- operator new应该内含一个无限循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler。它也应该有能力处理0 bytes申请。class专属版本则还应该处理“比正确大小更大的(错误)申请”
- operator delete应该在收到null指针时不做任何事。class专属版本则还应该处理“比正确大小更大的(错误)申请”(如果大小错误,调用标准版本的operator new和delete)
52:写了placement new也要写相应的placement delete
- new表达式先后调用operator new和default构造函数
- 当你写一个placement operator new,请确定也写出了对应的placement operator delete.如果没有这样做,你的程序可能会发生隐微而时断时续的内存泄漏(运行期系统寻找“参数个数和类型都与operator new相同”的某个operator delete,如果一个带额外参数的operator new没有“带相同额外参数”的对应版operator delete,那么当new的内存分配动作需要取消并恢复旧观时就没有任何operator delete会被调用)
- 当你声明placement new和placement delete,请确定不要无意识地遮掩了它们的正常版本
9.杂项讨论
53:不要轻忽编译器的警告
- 严肃对待编译器发出的警告信息,努力在你的编译器的最高(最严苛)警告级别下争取“无任何警告”的荣誉(在你打发某个警告信息之前,请确定你了解它意图说出的精确意义)
- 不要过度依赖编译器的报警能力,因为不同的编译器对待事情的态度并不相同。一旦移植到另一个编译器上,你原本依赖的警告信息有可能消失
54:让自己熟悉包括TR1在内的标准程序库
- 标准委员会于1998年核准了C++ standard,该标准程序库包括:STL(容器、迭代器、算法、函数对象等)、iostreams、国际化支持、数值处理、异常阶层体系以及C89标准程序库
- TR1详细叙述了14个新组件,放在std命名空间内(std::tr1)包括:智能指针、tr1::function、tr1::bind、Hash tables(用来实现sets、multisets、maps和multi-maps)、正则表达式、Tuples(变量组)、tr1::array、tr1::mem_fn(语句构造上与成员函数指针一致)、tr1::reference_wrapper(使容器“犹如持有references”)、随机数生成工具、数学特殊函数、C99兼容扩充以及Type traits(一组traits classes)、tr1::result_of(推导函数调用的返回类型)
55:让自己熟悉Boost
- Boost提供许多TR1组件实现品,以及其他许多程序库
总结自:《Effetive C++》中文第三版
更多推荐
所有评论(0)