Conclusion for Designs and Declarations

本文探讨了C++编程中如何设计易于正确使用且不易误用的接口,包括使用新类型预防错误、利用类型安全增强接口健壮性、通过智能指针管理资源等策略。并介绍了参数传递的最佳实践、如何避免对象切割问题、使用成员与非成员函数的考量以及封装的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

条款18:

1.欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。

例如:

class Date{
public:
	Date(int month, int day, int year);
	//...
};
客户可能很容易犯下至少两个错误:

第一,以错误的次序传递参数:

Date d(30,3,1995);
第二,传递一个无效的月份或天数:

Date d(2,30,1995);
2.许多客户端错误可以因为导入新类型而获得预防。我们导入简单的外覆类型来区别天数、月份和年份。

struct Day{
explict Day(int d):val(d){};
int val;
}
struct Month{
explicit Month(int m):val(m){}
int val;
};
struct Year{
explicit Year(int y):val(y){}
int val;
};
class Date{
public:
	void Date(const Month& m, const Day& d, const Year& y);
	//……
};
Date d(30, 3, 1995);                       //错误,不正确的类型
Date d(Day(30), Month(3), Year(1995));     //错误,不正确额类型
Date d(Month(3), Day(30), Year(1995));     //正确
3.可以利用enum表现月份,但enums不具备类型安全性,例如enums可被拿来当一个ints使用(条款2),比较安全的解法是预先定义所有有效的Months:

class Month{
public:
 static Month Jan(){ return Month(1);}
 static Month Feb(){ return Month(2);}
 ……
 private:
 explicit Month(int m);//只能在类内部使用,防止生成其他月份。
 ……
 };
Date d(Month::Mar(),Day(30),Year(1995));
4.预防客户端错误的另一个办法是,限制类型内什么事可以做,什么事不可以做。经常见到的是加上限制const。例如在条款3中,用const修饰operator*的返回值,这样就可以阻止客户因“用户自定义类型而犯错”

if(a*b=c)//这里其实打算做比较,而不是赋值
5.factory函数(条款13)返回一个指向Investment继承体系内的一个动态分配对象:

Investment* CreateInvestment();
为了避免资源泄露,CreateInvestment返回的指针必须被删除,这样客户就有了两个犯错误的机会:没有删除指针,或者删除了不止一次。可以将delete交给智能指针。但是为了防止用户忘记使用智能指针,fuctory函数返回一个智能指针:

shared_ptr<Investment> CreateInvestment();
6.让智能指针自带删除器

shared_prt<Investment> createInvestment()
 {
	shared_prt<Investment> retVal(static_cast<Investment*>(0),
									getRidOfInvestment);
	retVal=……;//令retVal指向正确对象
	return retVal;
 }
其中使用static_cast类型转换是因为shared_ptr构造函数的第一个参数必须是指针,而0不是指针。

7.shared_ptr一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在客户的错误:Corss-DLL Problem。这个问题发生于:对象在一个动态链接库DLL中被new创建,却在另一个DLL内被delete销毁。在许多平台上,这一类跨DLL之new/delete成对使用会导致运行期错误。shared_ptr没有这个问题,因为它的删除器来自其所诞生的那个DLL的delete。



条款20:

1.pass-by-value会调用复制构造函数和析构函数,成本高。pass-by-reference-to-const可以回避构造和析构。

class Person{
public:
	Person();
	virtual ~Person();
	……
private:
	std::string name;
	std::string address;
};
class Student :public Person{
public:
	Student();
	~Student();
	……
private:
	std::string schoolName;
	std::string schoolAddress;
}; 
现在考虑一个函数validateStudent,它需要一个Student实参,以pass by value方式传递。
bool validateStudent(Student s);//pass by value  
Student plato;
bool platIsOK = validateStudent(plato);
当函数被调用时,copy构造函数会被调用,用plato构造s。在返回时,s会被析构。那么pass by value的代价就是Student的一次构造和一次析构。但是Student构造和析构时又发生了什么?它内部有两个string对象,所以会有两个string对象的构造和析构。Student继承自Person,又加上Person的构造和析构,Person内又有两个string对象,因此还要加上2个string对象的构造和析构。总共是六次构造和六次析构。

事实上没有调用父类的copy 构造函数,而是普通构造函数,具体参考:http://blog.youkuaiyun.com/djb100316878/article/details/40857183

pass  by value是正确的,但是其效率低下。以pass by reference-to-const方式传递,可以回避所有构造函数和析构函数。

pass-by-reference-to-const可以回避所有构造和析构和析构函数。
bool validateStudent(const Student& s);
值传递不需const是因为只能对副本进行修改,现在以reference传递,为了防止被修改,必须加上const。

2.以by reference方式传递参数可以避免slicing(对象切割)问题:当一个派生类对象以by value方式传递给一个基类对象,base class的copy构造函数会被调用,而“造成此对象像derived class对象”的那些特化性质全被切割掉了,仅仅留下一个base class对象。

解决切割问题的办法就是:以by-reference-to-const的方法传递。

#include <iostream>  

using namespace std;

class Base
{
public:
	Base()
	{
		cout << "调用父类的构造函数" << endl;
	}
	Base(const Base& b)
	{
		cout << "调用的是父类的copy 构造函数" << endl;
	}
	~Base()
	{

	}

	virtual void display() const
	{
		cout << "调用的是父类的display函数" << endl;
	}
private:
	int i;
};

class Derived : public Base
{
public:
	Derived()
	{
		cout << "调用子类的构造函数" << endl;
	}
	~Derived()
	{

	}

	virtual void display() const
	{
		cout << "调用的是子类的display函数" << endl;

	}


};

void print(Base b)
{
	b.display();//参数被切割 即使传递子类对象,调用的也是父类的display函数  
}

void print2(const Base& b)
{
	b.display();
}
int main()
{

	Derived aa;
	print(aa); //这里调用了 父类的copy构造函数 父类的print函数;  

	print2(aa);
	return 0;
}
输出:
调用父类的构造函数
调用子类的构造函数
调用的是父类的copy 构造函数
调用的是父类的display函数
调用的是子类的display函数
3.对内置类型而言,当有机会选择采用pass-by-value或pass-by-reference-to-const时,选择pass-by-value并非没有道理。这适用于STL的迭代器和函数对象。(复制成本较低)。对于STL的迭代器和函数对象,用传值的比传址适合。
用迭代器遍历,是常用的。如果传个常量指针或引用进去,将丧失遍历的能力。如果传个引用或指针进去,可以遍历,但是会改变外部迭代器的值,一般情况下是没这种需求的。所以不如传值进去,复制一个迭代器,内部可以用,又不影响外部,这是常见情况。




条款21:

1.不要返回一些references指向某个local对象。

#include <iostream>  

using namespace std;

class Rational
{
public:
	Rational(int m = 0, int n = 0)
	{}
	~Rational()
	{}
private:
	int n, d;
	/*
	运算符重载形式有两种,重载为类的成员函数和重载为类的友元函数。
	当运算符重载为类的成员函数时,函数的参数个数比原来的操作个数要少一个;
	当重载为类的友元函数时,参数个数与原操作数个数相同。原因是重载为类的成员函数时,
	如果某个对象使用重载了的成员函数,自身的数据可以直接访问,就不需要再放在参数表中进行传递,
	少了的操作数就是该对象本身。而重载为友元函数时,友元函数对某个对象的数据进行操作,
	就必须通过该对象的名称来进行,因此使用到的参数都要进行传递,操作数的个数就不会有变化。
	*/
	friend const Rational operator*(const Rational& lhs, const Rational& rhs)
	{
		Rational temp;
		temp.n = lhs.n * rhs.n;
		temp.d = lhs.d * rhs.d;
		return temp;
	}
	/*
	这里为什么不能返回 const Rational& 呢?引文 temp是一个local对象,而local对象在函数退出的时候就销毁了,因此,如果
	这里返回const Rational&, 其实并没有返回reference指向某个Rational,它返回的reference指向一个"从前的"Rational,一个旧的
	Rational,一个曾经被当做Rational但是现在已经成空壳的残骸,因为它在函数退出的时候已经被销毁了。

	任何调用者甚至只是对此函数的返回值做任何一点点运用,都将立刻坠入"无定义行为"的恶地;

	总结:
	任何函数如果返回一个reference指向某个local对象,都将发生错误;
	任何函数如果返回一个指针指向一个local对象,结果也是一样的。

	*/
};

int main()
{
	Rational a(1, 2);
	Rational b(3, 5);
	Rational c = a*b;
	return 0;
}

2.不要返回references指向在heap内构建的对象。

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
	Rational* result = new Rational(lhs.n*rhs.n, lhs.d*rhs.d);
	return *result;
}
这样还是必须付出一个“构造函数调用”代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。此外,谁该对new出来的对象实施delete?

Rational w, x, y, z;
w = x*y*z;
这里使用了两次new,却没有合理的办法取得operator*返回的reference背后隐藏的那个指针,这绝对是资源泄露。
3.不要返回references指向一个被定义于函数内部的static Rational对象。
局部静态变量:函数调用结束后不会消失,而保留原值,即其占用的存储单元不释放,在下一次该函数调用时,该变量保留上一次函数调用结束时的值。

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
	static Rational result;
	//...
	return result;
}
这显然会造成多线程安全问题。(线程A的返回值是线程B的计算结果),更严重的问题如下:

bool operator==(const Rational& lhs, const Rational& rhs)
{}

Rational a,b,c,d;
if ((a*b) == (c*d))
{}
(a*b)==(c*d)会始终成立。
4.必要时让函数返回一个新对象

inline const Rational operator*(const Rational& lhs,const Rational& rhs)
{
	return Rational(lhs.n*rhs.n,lhs.d*rhs.d);
}



条款22:

1.将成员变量声明为非public,这样可以实现语法一致性,每样东西都是函数,都需要加小括号。

2.使用函数可以让你对成员变量的处理有更加精确的控制。如果成员变量为public,那么每个人都能读和写,但是如果通过函数读或写其值,那么就能实现“不准访问”、“只读访问”以及“读写访问”,甚至实现“惟写访问”。

3.封装:如果通过函数访问成员变量,日后可以用某个计算替换这个变量,这时class的客户却不知道内部实现已经变化。

例如,写一个自动测速的程序,汽车通过,其速度便填入一个速度收集器内:

class SpeedDataCollection{  
    ……  
public:  
    void addValue(int speed);//添加一笔新数据  
    double averageSoFar() const;//返回平均速度  
    ……  
};  
实现函数averageSoFar。一种做法是在class内设计一个变量,记录至今以来所有速度 的平均值;当averageSoFar被调用,只需要返回那个成员变量就好。另一种做法是让averageSoFar每次被调用时重新计算平均值,这个函数有权限读取收集器内的每一笔速度值。
上述第一种做法(随时保持平均值)会使每一个SpeedDataCollection对象变大,因为必须为用来存放目前平均值、累计总量、数据点数的每一个成员变量分配空间;但是这会使averageSoFar十分高效,它可以只是一个返回目前平均值的inline函数(条款30)。第二种做法,“每次被问询才计算平均值”会使得averageSoFar执行较慢,但是这时SpeedDataCollection对象占用空间比较小。

将成员变量隐藏在函数接口背后,可以为“所有可能的实现”提供弹性。

4.protected成员变量的封装性不比public高。某些东西的封装性与“当其内容改变时可能造成的代码破坏量”成反比。

取消一个public成员变量,所有使用它的客户码都会破坏。取消一个protected成员变量,所有使用它的继承类都会被破坏。

所以private提供封装,protected和public不提供封装。



条款23:

1.使用member和non-member哪一个好?

考虑一个class用来清除浏览器的一些记录,这个class中有清除告诉缓存区的函数,有清除访问过URLs的函数,还有清除cookies的函数:

class WebBrowser{  
public:  
    ……  
    void clearCash();  
    void clearHistory();  
    void removeCookies();  
    ……  
};  
使用member函数如下:

class WebBrowser{  
public:  
    ……  
    void clearEverything()  
    {  
        clearCash();  
        clearHistory();  
        removeCookies();  
    }  
    ……  
}; 
使用non-member如下:
void clearBrowser(WebBrowser& wb)  
{  
    wb.clearCash();  
    wb.clearHistory();  
    wb.removeCookies();  
};
使用non-member函数比较好。member函数带来的封装性比non-member函数低。此外,提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性,这可以降低编译相依度。
2.愈多东西被封装,欲少人可以看到它,我们就有愈大的弹性去改变它。愈少代码可以看到数据(访问数据),愈多数据可被封装,我们就更有自由来改变对象数据。愈多函数可以访问它,数据的封装性就愈低。

使用non-member和non-friend函数并不增加“能够访问class内之private成分”的函数数量。

2.friend函数对class private成员的访问权力和member函数相同,因此两者对封装的冲击力道也相同。

在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内。

namespace WebBrowserStuff{  
    class WebBrowser{……};  
    void clearBrowser(WebBrowser& we);  
    ……  
} 
3.namespace可以跨越多个源码文件而classes不能。一个WebBrowser这样的函数可能拥有大量的便利函数,书签、打印、cookie管理等有关,大多数客户对其中某些感兴趣,于是为了降低编译相依关系,可以分离他们。

将书签相关便利函数声明于一个头文件,将cookie相关函数声明于另一个头文件,再将打印相关函数声明到第三个头文件……。

//头文件webbrowser.h,这个头文件针对class WebBrowser自身及WebBrowser核心机能  
namespace WebBrowserStuff{  
    class WebBrowser{……};//核心机能  
    ……//non-member函数  
}  
//头文件webbrowserbookmarks.h  
namespace WebBrowserStuff{  
    ……//与书签相关的便利函数  
}  
//头文件webbrowsercookies.h  
namespace WebBrowserStuff{  
    ……//与cookie相关的便利函数  
}
标准库有数十个头文件(<vector>,<algorithm>,<memroy>等等),每个头文件声明std的某些机能。如果客户想使用vector相关机能,只需要#include<vector>即可。这也允许客户只对他们所用的那一小部分形成编译相依。


条款24:

1.如果所有参数都需要类型转换,则为此采用non-member函数。

class Rational{
public:
	Rational(int numerator = 0, int denominator = 1);//非explicit,允许隐式转换  
	int numerator() const;                           //分子访问函数
	int denominator() const;                         //分母访问函数
private:
	//...
};
如果要支持加减乘除等运算,这时重载运算符时是应该重载为member函数还是non-member函数呢,或者non-member friend函数?
如果写成member函数
class Rational{  
public:  
    ……  
    const Rational operator*(const Rational& rhs);  
    ……  
};
如果进行混合运算:
result=oneHalf*2;//正确,相当于oneHalf.operator*(2);  
result=2*oneHalf;//错误,相当于2.operator*(oneHalf);  
不能满足交换律。因为2不是Rational类型,不能作为左操作数。oneHalf*2会把2隐式转换为Rational类型。
上面两种做法,第一种可以发生隐式转换,第二种却不可以,这是因为只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。第二种做法,还没到到”参数被列于参数列内“,2不是Rational类型,不会调用operator*。
result=operator*(2,oneHalf);
如果要支持混合运算,可以让operator*成为一个non-member函数,这样编译器可以在实参身上执行隐式类型转换。
const Rational operator*(const Rational& lhs, const Rational& rhs); 
这样就可以进行混合运算了(两个参数都在参数列内,都可以隐式转换)。那么还有一个问题就是,是否应该是operator*成为friend函数。如果可以通过public接口,来获取内部数据,那么可以不是friend函数,否则,如果读取private数据,那么要成为friend函数。这里还有一个重要结论:member函数的反面是non-member函数,不是friend函数。如果可以避免成为friend函数,那么最好避免,因为friend的封装低于非friend。


条款25:
1.pimpl手法

class WidgetImpl{            //针对Widget数据而设计的class
public:
	//……
private:
	int a, b, c;              //数据很多,复制意味时间很长
	std::vector<double> b;
	//……
};
class Widget{
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs
    {
        ……          //复制Widget时,复制WidgetImpl对象              
        *pImpl=*(ths.pImpl);
        ……
    }
    ……
private:
    WidgetImpl* pImpl;//指针,含有Widget的数据
};
2.将std::swap针对Widget特化:当Widgets被置换时,真正该做的是置换其内部的pImpl指针。
namespace std{
    template<>      //这是std::swap针对T是Widget的特换版本,
    void swap<Widget>(Widget& a, Widget& b) //目前还无法编译
    {       //只需要置换指针
        swap(a.pImpl, b.pImpl); 
    }
}
“template<>”表示它是std::swap的一个全特化版本。
3.也许你已经发现了,上面的无法通过编译,因为pImpl是private成员,一般函数无法访问。我们可以这样处理:

class Widget{
public:
    ……
    void swap(Widget& other)
    {
        using std::swap;//这个声明有必要
        swap(pImpl, other.pImpl);    //这样可以????
    }
    ……
};
namespace std{
    template<> //修订后的swap版本
    void swap<Widget>(Widget& a, Widget& b)
    {
        a.swap(b);  //调用其成员函数
    }
}
是不是很奇怪上面other.pImpl。为什么在class Widget里面可以访问other对象的私有成员???下面测试程序确实不报错。

#include <iostream>  
#include <vector>
using namespace std;

class WidgetImpl{            //针对Widget数据而设计的class
public:
private:
	int a1, a2, a3;              //数据很多,复制意味时间很长
	std::vector<double> a4;
};

class Widget{
public:
	Widget() :pImpl(new WidgetImpl){}
	~Widget(){ delete pImpl; }
	void swap(Widget& other)
	{
		using std::swap;//<span style="color:#ff0000;">这个声明有必要</span>
		swap(pImpl, other.pImpl);    //确实没报错
	}
private:
	WidgetImpl* pImpl;
};
namespace std{
	template<> //修订后的swap版本
	void swap<Widget>(Widget& a, Widget& b)
	{
		a.swap(b);  //调用其成员函数
	}
}
int main()
{
	Widget w1, w2;
	std::swap(w1,w2);
	return 0;
}
注:这种做法不仅可以通过编译,还与STL容器有一致性,因为所有STL容器也都提供有public swap成员函数和std::swap特化版本(用以调用前者)。

4.如果是类模板,则特化会出问题。

template<typename T>
class WidgetImpl{……};
template<typename T>
class Widget{……};
在Widget内放个swap成员函数很简单,但是在特化std::swap时却有问题。

namespace std{
    template<typename T>
    void swap<Widget<T> >(Widget<T>& a,//不合法,错误
                          Widget<T>& b)
    {
        a.swap(b);
    }
}
错误原因:我们企图偏特化一个function template(std::swap),但C++只允许对class template偏特化,在function template身上偏特化是不行的。

5.当打算偏特化一个function template时,惯常做法是简单地为它添加一个重载版:

namespace std{
    template<typename T>//std::swap一个重载版本
    void swap(Widget<T>& a,//swap后面没有<……>
              Widget<T>& b)//这个也不合法
    {
        a.swap(b);
    }
}
一般重载function template没有问题,但是std管理比较特殊。客户可以全特化std内的templates,但不能添加新的templates到std里面。

namespace WidgetStuff{
   ……//模板化的WidgetImpl等
   template<typename T>//内含swap函数
   class Widget{……};
   ……
   template<typename T>
   void swap(Widget<T>& a,//non-member,不属于std命名空间
             Widget<T>& b)
   {
       a.swap(b);
   }
}
现在我们没有加在std空间里面,这样就不是std::swap()的特化或者重载版本了。
6.如果想让“class专属版”swap在尽可能多的语境下被调用,需要同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap;//令std::swap在此函数内可用
    ……
    swap(obj1, obj2);//位T类型调用最佳版本swap
    ……
}

C++的名称查找法则(name lookup rules)确保找到global作用域或T所在命名空间内的任何T专属的swap。如果T是Widget并在命名空间WidgetStuff内,编译器或使用“实参取决之查找规则”(argument-dependent lookup)找到WidgetStuff内的swap,如果没有专属版的swap,那么会调用std内的swap(因为使用了using std::swap)。
不要使用

std::swap(obj1,obj2);
这样会让编译器只认std::swap函数。






评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值