首页 > 代码库 > Effective c++(笔记) 之 类与函数的设计声明中常遇到的问题

Effective c++(笔记) 之 类与函数的设计声明中常遇到的问题

1.当我们开始去敲代码的时候,想过这个问题么?怎么去设计一个类?

或者对于程序员来说,写代码真的就如同搬砖一样,每天都干的事情,但是我们是否曾想过,在c++的代码中怎么样去设计一个类?我觉得这个问题可比我们“搬砖”重要的多,大家说不是么?

这个答案在本博客中会细细道来,当我们设计一个类时,其实会出现很多问题,例如:我们是否应该在类中编写copy constructor 和assignment运算符(这个上篇博客中已说明),另外,我们是让编写的函数成为类的成员函数还是友元还是非成员函数,函数的参数使用传引用的方式还是传值的方式,这个函数该不该声明为const,函数的返回值是该设计成const么?等一系列的问题,我会在下文分成各个小问题来解释。

首先,我觉得应该考虑的一个重要问题,我们设计的类该有多大,每当有新的需求时,我们是否应该随意的添加。

请在设计类的时候遵循下面的原则:

让你的class类接口即完美又最小化

原因------设计的类就相当于定义了一个新的类型,如果不能满足我们的需求,那么就不用谈其他的了,所以首先我们应该让设计的类满足我们的需求,在满足我们的需求时,尽可能的使类最小化,类的最小化是指,当类有新的需求时,我们看这个需求是否跟已经编写的函数冲突,是否可以和以前的整合,也就是说看这个成员函数是否是必要写到类里面的,因为大型class接口的缺点可维护性差。


2.类中的数据成员是设计成public、protected还是private?

答案:尽量使自己的data members设计成私有,不让外部访问,使其封装性更好,如果类的数据成员设计成public的话,外界随便访问,这对于c++的封装性而言就不是很好。

我们通常设计为pirvate,如果需要得到或者改变这些值,我们会编写专门的成员函数来操作,如下所示

int GetX() const { return x;}
void SetX(int value) { x = value;}

如果所设计的类为基类,同时希望基类的数据成员被派生类继承,那么一个很好的方法常常将数据成员设计为保护类型protected

这样,当继承方式为公有继承时,基类的公有成员和保护成员均以原有的状态继承下来,派生类中的成员函数和友元可以访问到基类的数据成员。


3.类class与结构体struct 的区别在哪里?

答:定义类等于定义了一种新的类型,结构体其实也可以达到这样的结果,有两个非常明显的不同点

类中如果不注明数据成员的访问级别默认的数据成员是私有的private,当继承的时候,如果不注明继承方式,则是私有private继承

结构体正好相反,它定义是默认的数据成员是公有的public,同时它的默认继承方式也是公有public的


4.设计类的函数时,应该将其设计为成员函数、非成员函数还是友元函数?

答:首先简单说一下它们之间的区别

成员函数与非成员函数最大的区别是---------------成员函数可以为虚函数,而其他的函数不可以为虚函数(最大最明显的区别)。

友元函数相当于该类的一个朋友友元,它可以访问该类的所有数据成员,但有时候朋友多并不是什么好事,所以,在设计类的时候,如果这个函数不能为成员函数但同时它又必须访问到该类的数据成员(输入>> 输出<< 操作符重载)此时再设计成友元,如果可能不设计为友元那就尽量这样。

用一个例子来教我们怎么判断这个函数是设计成成员函数还是非成员函数!

下面是一个分数的类,其中有个实现分数乘法的函数,我们暂且先将它设计为类的成员函数来讨论

class Rational{
public:
	Rational( int numerator = 0 , int denominator = 1);
	int numerator() const;
	int denominator() const;
	//分数的乘法,开始设计成类的成员函数
	const Rational operator*(const Rational &rhs) const;
private:
	int n , d;//分别表示分子和分母
};
int Rational::numerator() const
{
	return n;
}
int Rational::denominator() const
{
	return d;
}
const Rational Rational::operator *(const Rational &rhs) const
{
	Rational result;
	result.n = n * rhs.n;
	result.d = d * rhs.d;
	return result;
}

看上述代码,将Rational类中的分数乘法设计为类的成员函数看似没什么错误,看下面的实例就知道了

Rational oneHalf(1,2);
Rational oneEight(1,8);
Rational res;
res = oneHalf * oneEight;
res = oneHalf *2;
res = 2 * oneHalf;//报错

如果我们写成上述的成员函数,那么乘号*左边一定得是Rational对象,因为成员函数的形式决定了这样,

在此你可能会说不对,两边都必须是Rational对象,乘号*右边可以为int对象,原因如下:

当我们在类中的构造函数设计为

Rational( int numerator = 0 , int denominator = 1);

而不是

explicit Rational( int numerator = 0 , int denominator = 1);

这二者的区别就是,当函数的参数是类类型的时候,当你传入函数的参数并不是类类型,恰该类类型的构造函数没有申明explicit,那么便会产生隐式转换,使int ------->   Rational,其内部的转换过程大致如下:

//运用构造函数的隐式转换产生一个临时的类类型对象
const Rational temp(2);
//用该临时对象替换int的值
res = oneHalf * temp;

再次说明:

只有当类类型的构造函数没有声明explicit时才会产生类型转换

这里的类型转换只针对于出现在参数表上的类类型形参有效,而对于(*this)是无效的,所以最后一个实例会报错,因为左边不会进行转换。

所以这样看来将分数乘法的这个函数设计为成员函数显然不合理,因为没办法乘号*左边是int类型的数据,这跟现实不符合。

有人说了再写一个类似能实现左边是int值的函数就可以了,别忘了我们设计类最初要遵循的:尽量接口最小化,我们完全可以通过一个函数实现所有的类型的分数相乘,为什么要再写一个函数呢,如果再写一个函数就违背了接口最小化的原则。

我们可以这样改变operator*函数 , 将其设计为非成员函数,如下所示

const Rational operator *(const Rational &lhs , const Rational &rhs) 
{
	return Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
}

这样参数为两个均为Rational类的引用,都为参数,在构造函数不为explicit时,均可以进行隐式转换,实现了左右两边都可以是int型数据的可能。同时类中的成员设计为私有,通过numerator() 和denominator()来访问,提高了类的封装性,参数的形式采用了引用的方式,当调用该函数时,不用复制实参,提高了效率,返回值采用了const的形式,避免了分数乘积的结果被写的危险,即有效率又有安全性的写法。

很多情况下,都需要重载输入输出操作符,常常为了我们的编程习惯,把输入输出操作符重载的函数设计成了类的非成员友元函数。

istream& operator>>(istream &in , T &object)
{//因为输入改变了对象的状态,参数不能为const
	//输出类的成员操作
	return in;
}

ostream& operator<<(ostream &out , const T &object)
{//输出没有改变对象的状态,设置为const引用的方式

	return out;
}

结论

虚拟函数必须为类的成员函数

类的加减乘除运算符重载常常设计为类的非成员函数

输入输出操作符重载的函数一定不能为类的成员函数,常常为类的友元函数

只有非成员函数才能在其左端对象(形参)身上使用类型转换


5.函数的参数尽量使用传址方式,而少使用传值方式

这条几乎是c++领域中公认的规则,在编写函数中尽量使用引用(传址)方式,而少用c语言中的传值方式

原因有下面两点

传址方式效率比较高,不用在调用函数的时候复制实参调用拷贝构造函数,通常将对象以传值的方式进行时,将实参复制给形参需要调用copy constructor 当返回的时候将返回值传回对象又调用copy constructor 完了之后对局部对象会析构掉,还要调用析构函数,这样的效率可知。

另外,当使用传址的方式时,可以避免派生类对象传入参数为基类对象的函数时发生的切割现象,当用传址的方式,该基类对象的引用绑定的是派生类对象,所以在执行这个函数里中如果该对象调用了虚函数,那么就会根据其绑定的动态对象来决定执行基类还是派生类的对象,这样很容易达到我们的目的。


6. 当返回值是对象时,尽量不要采用传回引用的方式

其实这点我感觉Effective c++没有说清楚,我认为,当函数是类的成员函数,通常返回的应该是该类的引用,为什么呢?因为Effective c++上说这条的原因是,如果返回的是对象,采用引用的方式,通常该引用指向不存在的对象,但是在类的成员函数中,常以T&作为返回值,是因为调用该成员函数的对象肯定存在我们常常返回时*this,所以对于成员函数而言返回*this不可能指向不存在的对象。

所以我感觉书上这点应该指的是类的非成员函数,当类的非成员函数返回对象时,我们的确不要返回该对象的引用。

传引用无非目标就是避免调用构造函数使效率提高,但是因为传回的引用必然要绑定对象(因为引用其实就是别名,为某个对象某个变量起了另外一个名字,改变这个别名也就等于改变了本身,操作这个引用也就等于操作了本身) ,所以我们必然要产生一个对象,这样返回时,引用才能指向一个对象,栈空间和堆空间中产生

凡是局部变量都是在栈空间中产生的,

凡是通过new出来的都是在堆空间中产生的

还是拿上面的那个分数乘法的例子继续讨论

//传回非引用的对象
const Rational operator *(const Rational &lhs , const Rational &rhs) 
{
	return Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
}
//传回引用的对象
const Rational& operator*(const Rational &lhs , const Rational &rhs)
{
	//在栈空间产生result
	Rational result(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
	return result;
}

const Rational& operator*(const Rational &lhs , const Rational &rhs)
{
	//在堆空间中产生result
	Rational *result = new Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
	return *result;
}

首先,在栈空间中产生的result,仍然调用了构造函数,效率完全没有提高,另外一个最大的bug是你传回了局部对象,这样当函数执行完后局部对象就析构不存在了,这是非常大的错误。

如果在堆空间中产生result,new产生的还是需要调用构造函数,同时也存在一个bug,那就是你new了什么时候delete哈!new和delete必须要配对产生哈!

所以,在非成员函数中,如果返回的是对象时,尽量返回值尽量为对象而不应该为对象的引用

7.什么时候应该使用const?

其实,在初学c++的时候感觉最郁闷的就是这个const关键字了,它无时不刻存在所有的c++代码中,让我看得头晕眼花。

const表示常量,在定义全局变量时我们常用到,如下所示

const int M = 1000000007;
const double ASPECT_RATIO = 1.653;

这是在全局作用域定义一些常量,别告诉我你还在用#define,可以改改旧的c语言习惯了哈!

另外在类的成员函数的末尾也经常见到这个关键字const,如下所示

int numerator() const;
int denominator() const;

这表示调用该成员函数的对象的数据成员不可更改,说的简单点就是这个函数中隐藏的this指针所指向的对象时const对象(注意,指针并不是const,而是指向的对象时const对象)

在函数的返回值时有时也能见到const

这里表示返回的对象时const,不能被写,只能读,函数传回的是一个常量值。如下所示

const Rational operator *(const Rational &lhs , const Rational &rhs) 
{
	return Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
}
Rational a , b , c;
(a*b) = c //报错,这样是错误的,返回的是const常量,不能赋值

常常函数的参数我们也使用const 引用的方式

此处,如果在函数中不打算改变参数的数据成员,就尽量设置成const,这样当你不注意在函数体内试图改变参数时编译器便会给提醒。

怎么去区别const关键字是限制指针还是变量的,下面是一种最简单的方法

const char *p = "Hello"; //非const指针指向const变量
char * const p = "Hello";//const指针指向非const变量
const char * const p = "Hello";//const指针指向const变量

以*号为分界线,const在左边就是限制变量,即指向的是const的变量,const在*号右边指的是指针不能修改,const指针。


8.如果不想用编译器产生的默认函数,尽量显示的拒绝这个函数

怎么去拒绝编译器产生的默认函数,将它定义为类的私有方式即可,这样实例化的对象也可以是客户就没法调用这个函数,或者尝试去调用这个函数时,编译器会提示错误。

为什么要去拒绝编译器为我们产生的默认函数,比如默认的赋值操作符

当我们定义数据类的时候,数组是不能赋值的,需要循环才可以,所以在设计类的时候,便不想允许这个函数存在,虽然自己没有编写,但是编译器依然还会给我们合成一个,所以此时我们就必须显示指出这个函数不能调用,如下所示

template<class T>
class Array{
private:
	//显示的指出不定义这个函数
	Array& operator=(const Array &rhs);
};