C++我们必须要熟悉的事之具体做法(3)——类的设计与声明

1. 让接口被正确使用

最重要的方法是:保持与内置类型的一致性。

方法1:外覆类型(wrapper types)

例如在需要年月日时,使用

struct day {

explicit day(int d) : val(d) { }

private:

int val;

};

方法2:函数替代对象

class month {

public:

static month jan() { return month(1); }

private:

explicit month(int);    //禁止生成新的月

};

month::jan();等等

方法3:返回至限制为cont作为右值

方法4: 返回指针时,返回shared_ptr类型

testclass *create();虽然使用shared_ptr可以避免delete,但是最好像这样申明。

shared_ptr<testclass> create();

这个方法我们可以指定删除器,还可以“cross-DLL problem”(在一个DLL中new在另外一个DLL中delete)。

2. 设计类时就像设计一个type

类代表了一个新的类型和新的作用域。

需要考虑的问题:

(1) 是否需要新type

可能使用derived class、一个或多个non-member函数或者模板就能达到要求。

(2) 一般性

是否要作为class template。

(3) 新的类型如何创建和销毁

这影响类的构造函数、析构函数。

首先考虑我们需不需要自己写构造函数:当类含有普通的内置类型、指针时需要我们自己编写构造函数。

自己编写构造函数: 要考虑是否要构造基类、是否为explicit、参数传递是否const、是否要&。

往往当类:含有指针、需要复制资源时需要析构函数,我们可能可以通过智能避免需要自己写析构函数。

(4)对象的复制控制

copy构造函数的行为、copy assign的行为,这里需要考虑是否有继承,要复制基类的数据。

追重要的是考虑我们是否需要这两个函数。

若不需要我们就明确禁止它们。

若需要,则考虑默认实现是否能满足我们的要求,若可以则不必写。

一般当需要析构函数时,也需要它们两个。

(5) 是否需要转换

若禁止其他类型转化为本类类型,则可使单实参的构造函数为explicit。

若类需要转换为其他类型,考虑重载operator。

(6) 新类型的合法值

这个主要涉及到某些成员函数(构造函数、赋值操作符和setter函数等)的错误检测。

(7) 类的继承关系

若类继承了某些类或者作为基类,则需要考虑哪些成员函数需要为virtual、哪些不需要。

作为基类时,往往要使析构函数为virtual。

(8)考虑数据成员

哪些为public、protect、private。

非需要继承的都为private、否则protect。

是否static成员、是否const成员、是否是&。

(9) 考虑函数

哪些函数需要成为它的成员函数、哪些非成员、哪些函数和类是friend。

我们需要哪些成员函数,哪些作为public、哪些作为protect、private。

函数接口:是否为virtual、是否const成员函数、形参是否const是否要&、返回值是否const是否&。

(10) 未声明接口

对效率、异常安全性、资源运用提供什么保证,这些保证将为你的类加上相应的约束条件。

3. 考虑reference-to-const作为参数传递

在by value传递参数时,传递的是副本,对于类设计到类的复制构造函数、析构函数。带来额外的开销。

我们多数情况下应该by reference-to-const作为参数传递,这样有两个好处:

(1) 避免了复制和析构。提高了效率。const一般是必须的,这使我们不能更改参数,同时const&可以绑定到右值。

(2) 可以避免slicing(对象切割)问题。by value方式传递参数时,若派生类传递给基类时会发生切割,只传递了基类的部分。

误解:有的人认为小的对象应该使用by value方式传递。

理由:(1) 小类型复制构造函数代价不高比如一个指针,但是我们复制这种对象却要“复制那些指针所指的每样东西”,代价可能就高了。

(2) 某些编译器拒绝将用户自定义类型放入缓存中,可能降低效率。而引用往往是用指针实现的,传递引用通常意味着真正传递的是指针。而指针肯定会被放入到缓存中。

(3) 小型类型作为一个用户自定义的类型,其大小可能会变化,将来可能会变大。甚至在不同的编译器中大小可能都不同,如:string的不同实现在不同编译器中的大小可能不同。

但是有些参数适合by value方式传递:包括内置类型(c语言中就是这么做的)、STL迭代器(一个智能指针)、函数对象(一个定义了operator()的类)。

其他的往往都是传递引用比较好。

4. 不是所有函数都可以返回引用,该返回对象时就应该这么做!

引用指向并不存在的对象肯定会造成错误,例如指向函数内部的局部变量等等。

对于有些函数我们妄想返回引用,肯定是错误的。无论是指向内部new的对象(谁来delete的问题)、一个static变量(函数的多次调用结果却是相同的)等等。

这些函数往往的特征是需要满足参数满足交换率,例如+、*、==等等。

这些函数往往都是类的non member函数(因为要满足交换律,左右参数都需要实现隐式类型转换)、但是却是friend(需要访问了的成员函数)的函数。

它们只能返回对象不能返回引用,因为它们不是成员函数,没有this指针引用不能只想类内部的数据成员。

往往作为成员函数的函数可以返回引用,例如:&operator[], opreator*等等。

5. 类相关的函数何时成为non-member

这些相关函数往往就是类的需要operator的函数,往往是在所有参数都需要类型转换时成为non member函数,典型的是operator+、operator*(乘号)、operaotr==等等。

这些函数需要满足交换率,两边都需要隐式类型转换。而只有参数类表中的形参才会被执行隐式转换,this指针绝不会执行隐式类型转换。

我们往往令这些函数为non member函数,不需要是友元,而且若要访问成员变量而可通过成员函数,而且他们的构造函数必须不能是explicit。

同时也证明了:若不能成为member函数,应该作为non member函数,而不是成为friend函数。

但是在template编程中,opreator+等重载函数设为friend,但是目的却不是为了访问其数据成员,而是模板实例化所必须的。

6. 成员函数应该声明为private

此时通过成员函数访问数据成员成为唯一的方式,可以满足语法的一致性。

使用成员函数可以让你对成员变量的处理有更精确地控制。若成员变量为public这样就可能被无限多的函数访问它,我们就不能控制了。

最最重要的是:封装:将成员变量隐藏在函数接口的背后,可以为所有可能的实现提供弹性。当我们更改成员函数的不同实现形式时,不必重新修改函数接口,可以从一个较好实现中受益。

若果你对客户隐藏成员变量(就是封装它们),你可以确保class的约束条件总会获得维护,因为只有成员函数可以修改它们,确保了你日后变更实现的权利。

同样的道理也适用于protected,包括语法一致性、细微划分之访问控制和封装。

“成员的封装性”与“当其内容改变是可能造成的代码破坏量”成反比。

对于public成员变量,取消时所有使用它的客户码都会被破坏,只是一个不可知的量。

对于protected成员变量,所有使用它的derived类都会被破坏,往往也是一个不可知的量。

所以protected成员变量也想public一样缺乏封装性。

因此从封装的角度,只有两种权限:private(封装)和其他(不封装)。

7. 用non-member、non-member替换member函数

当可以用类的成员函数组合成一个功能函数时,或者说提供相同的功能时,是把这个函数作为non-member、non-friend更好,而不是member。

作为成员函数,则多了一个成员函数访问数据成员,降低了类的封装性。

数据的封装性可用:越多函数可访问它,封装性越低来粗略衡量。

我们可以将non-member函数可以放入多了头文件但是隶属同一个命名空间中。命名空间可以跨越多个源文件,客户可以扩充这种函数。

这也是STL的组织形式。

8.不抛出异常的swap函数

swap是异常安全性的脊柱、处理自我赋值的常用机制, 原本只是STL的一部分。

8.1. 最典型的实现为:

#include <utility>

template<typename T>

void swap(T &lhs, T &rhs)

{

T tmp(move(lhs));     //move语义,tmp值变成lhs的值,lhs变成默认构造下的值。一般对于内置类型不变,但是如string等会变为空“”。

lhs = move(rhs);

rhs = (tmp);
}

需要满足:copy构造函数、copy assignment操作符。

对于所有STL容器类型,都会有一个成员函数的swap,并在std中完全特化一个swap调用STL成员的swap。

例如:对于vector,内部会定义了一个成员函数

template<typename T>

void vector<T>::swap(vector<T> &rhs)

{

//仅仅交换内部指针

start = rhs.start;

finish = rhs.finish;

end_of_storage = rhs.end_of_storage;
}

而在std中会定义一个完全特化版本:

namespace std {

template<typename T>

void swap<vector>(vector<T> &lhs, vector<T> &rhs)

{

//调用内部版本

lhs.swap(rhs);
        }
};

8.2. 普通类中实现swap

对于那种以指针指向一个对象,内含真正数据的类型,也就是使用“pimpl”(pointer to implementation)使用指针去实现的方式最需要自己的swap。

例如对于类:

class testclass {

public:

testclass(const testclass &rhs);

testclass& opreator=(const testclass &rhs);

private:

bigclass *pbc;      //指针所指对象复制需要花时间
};

类似于STL的做法:

在类内定义成员swap(), 在std中定义特化版本。

对于std命名空间我们不允许改变空间内的任何东西,但是我们可以为标准模板(如swap)制造特化版本。

class testclass {

public:

void swap(testclass &rhs)

{

using std::swap;

swap(pbc, rhs.pbc);        //置换对象我们只是置换指针
}

private:

bigclass *pbc;      //指针所指对象复制需要花时间
};

namespace std {

//完全特化版本

template<typename T>

void swap<testclass>(testclass &lhs, testclass &rhs)

{

//调用内部版本

lhs.swap(rhs);
        }
};

8.3. 类模板的swap

对于类模板

template<typename T>
testclass {          //内含swap
}:

由于函数模板不支持偏特化,只有类模板支持。所以不能定义下面的这种类型:

namespace std {

//偏特化版本:不支持,错误的。

template<typename T>

void swap<testclass<T> >(testclass<T> &lhs, testclass<T> &rhs)

{

lhs.swap(rhs);
        }
};

正确的做法是定义一个swap的重载版本,但是不能放在std中,我们允许在std中添加东西,只能完全特化其中的模板。

可以将swap放在我们自己的命名空间中。

namespace mystd {

template<typename T>    
testclass {       //内含swap
}:

//一个重载版本

template<typename T>

void swap (testclass<T> &lhs, testclass<T> &rhs)

{

lhs.swap(rhs);
        }
};

在swap(testclass1, testclass2);时,根据C++名称查找法则(name lookup rules)具体的是argument-dependent-lookup或Koenig-lookup法则会找到mystd中的testclass专属版本。

使用的方式:

template<typename T>

void dosomething(T &obj1, T &obj2)

{

using std::swap;              //使可以使用STL中swap

swap(obj1, obj2);            //根据实参相依查找:(1) 在全局作用于或者obj所在命名空间查找专用的swap(模板类需要重载版本);

//(2) 在std中查找swap的特化版本(对于普通类)。(3) 使用swap的一般化版本。
}

总结:

(1) 当std::swap效率不高时(往往是class或template class使用了pimpl手法),考虑提供一个public成员swap成员函数,

让它高效的置换你的类型和两个对象值,并确保这个函数不抛出异常。因为swap最好的应用是提供强烈的异常安全性,在这里不能抛出异常。

(2)对于class或这template应该提供一个non-member swap来调用member swap。对于class还应该提供一个完全特化的std::swap。

(3) 调用swap,使用using 声明式,以便使std::swap在你的函数内曝光课件,然后不加任何命名空间修饰符的使用swap。

(4) 为“用户自定义类型”进行std namespace全特化是好的,但是不要在std内加入std而言全新的东西。

时间: 2024-08-04 13:56:14

C++我们必须要熟悉的事之具体做法(3)——类的设计与声明的相关文章

C++的那些事:流与IO类

1.流的概念 "流"就是"流动",是物质从一处向另一处流动的过程,比如我们能感知到的水流.C++的流是指信息从外部输入设备(如键盘和磁盘)向计算机内部(即内存)输入和从内存向外部输出设备(如显示器和磁盘)输出的过程,这种输入输出过程被形象地比喻为"流". 为了实现信息的内外流动,C++系统定义了I/O类库,其中的每一个类都称作相应的流或流类,用以完成某一方面的功能.根据一个流类定义的对象也时常被称为流. 通常标准输入或标准输出设备显示器称为标准流

C++我们必须要了解的事之具体做法(1)——构造、复制构造、析构、赋值操作符背后的故事

1. C++默认调用哪些函数 当类中的数据成员类型是trival数据类型(就是原c语言的struct类型)时,编译器默认不会创建ctor. copy ctor.assign operator.dctor. 只有在这些函数被调用时,编译器才会创建他们. 这时候我们要自己创建构造函数,初始化内置数据类型.一般我们不需要复制控制函数,当需要时编译器合成的就很好.一般编译器合成的复制控制函数只是简单的复制成员,若能满足要求就不需要自己写. 当类中含有引用.const成员时,必须在初始化列表中初始化成员.

Java堆、栈和常量池以及相关String的详细讲解(转)

一:在JAVA中,有六个不同的地方可以存储数据: 1. 寄存器(register). 这是最快的存储区,因为它位于不同于其他存储区的地方--处理器内部.但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配.你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象. ------最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制. 2. 堆栈(stack).位于通用RAM中,但通过它的"堆栈指针"可以从处理器哪里获得支持.堆栈指针若向下移动,则分配新的内存:若向上移动

Java堆、栈和常量池以及相关String的详细讲解(经典中的经典)

博客分类: Java综合 一:在JAVA中,有六个不同的地方可以存储数据: 1. 寄存器(register). 这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部.但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配.你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象. ------最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制. 2. 堆栈(stack).位于通用RAM中,但通过它的“堆栈指针”可以从处理器哪里获得支持.堆栈指针若向下移动,则分配新的

java的堆,栈,静态代码区 详解

面试中,有家公司做数据库开发的,对内存要求比较高,考到了这个 一:在JAVA中,有六个不同的地方可以存储数据: 1. 寄存器(register). 这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部.但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配.你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象. ------最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制. 2. 栈(stack).位于通用RAM中,但通过它的“栈指针”可以从处理器哪里获得支持

Java堆、栈和常量池以及相关String的详细讲解

一:在JAVA中,有六个不同的地方可以存储数据:   1. 寄存器(register). 这是最快的存储区,因为它位于不同于其他存储区的地方--处理器内部.但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配.你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象. ------最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制. 2. 栈(stack).位于通用RAM中,但通过它的"堆栈指针"可以从处理器哪里获得支持.堆栈指针若向下移动,则分配新的内存:若向上移

Java堆/栈/常量池以及String的详细详解(转)------经典易懂系统

一:在JAVA中,有六个不同的地方可以存储数据: 1. 寄存器(register). 这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部.但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配.你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象. ------最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制. 2. 堆栈(stack).位于通用RAM中,但通过它的“堆栈指针”可以从处理器哪里获得支持.堆栈指针若向下移动,则分配新的内存:若向上移动,则释放那

java堆栈相关知识

Java栈与堆 本博客内容由网上搜集而来,作者加以修改整理而成 1. 栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方.与C++不同,Java自动管理栈和堆程序员不能直接地设置栈或堆. 2. 栈的优势是,存取速度比堆要快,仅次于直接位于CPU中的寄存器.但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性.另外,栈数据可以共 享,详见第3点.堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据.但缺点是,由

java中变量、对象的存储

内容转自网上看到的一篇博文,讲的很不错. 1. 栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方.与C++不同,Java自动管理栈和堆,程序员不能直接地设置栈或堆.2. 栈的优势是,存取速度比堆要快,仅次于直接位于CPU中的寄存器.但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性.另外,栈数据可以共 享,详见第3点.堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据.但缺点是,由于要 在运行时动态分配