《随笔二十五》—— “ 【Effective C++ 中文第三版】 提炼总结二”

本文深入探讨了C++编程中的最佳实践,包括:避免返回对象引用,确保变量私有,选择非成员函数替代成员函数,考虑函数参数的类型转换,延迟变量定义,减少类型转换,避免暴露对象内部组件的句柄,以及理解内联函数的使用策略。这些技巧旨在提高代码的封装性、效率和安全性。
摘要由CSDN通过智能技术生成

目录

条款21 当必须返回对象时,不要尝试返回其引用

条款22. 将变量声明为private

*条款23:宁以non-member、non-frined替换member函数

条款24: 若所有参数皆需类型转换, 请为此采用 non-member 函数

条款26:尽可能延后变量定义式的出现时间

条款27:尽量少使用转型动作

条款28:避免返回handles指向对象内部成分

条款30:透彻了解inlining的里里外外


条款21 当必须返回对象时,不要尝试返回其引用


● 前一小节已经讨论过,pass by vaule的代价有时候是巨大的,pass by refrence比较方便。那么肯定也有人会立刻想到,函数返回值的时候,能不能也采用这种办法来提高程序的效率呢?

为了能够简单且说明问题,这里选择了对于内置类型返回其reference:

int& func()
{
	int i=3;
	return i;
}
 
void doNothing(int i)
{
}
 
int main()
{
	int& k=func();
 
	doNothing(7); //注释此行,看看有何区别
 
	cout <<k <<endl;
	return 0;
}

首先,返回局部变量的引用是不对的。因为如果你返回一个引用,引用肯定是一个对象的别名,那么你一定要先反问自己:这个引用所代表的实际值到底是谁呢?想到这里,我们就一目了然了:你返回的引用是局部变量 i 的别名,但是 i 在函数结束以后,就被释放了!所以此时的返回的是一个垃圾数字。这个程序奇怪的地方在于,如果把doNothing函数注释掉,那么程序竟然能得出正确的值,这里的奥秘在于如果调用了函数,那么栈的内容有可能发生改变,改变之后,程序就得出合理的错误值了。而当这个函数调用被注释掉以后,由于栈的内容没有改变,所以即使 i 已经被释放了,但是它并没有被清零,值还是3,我还是可以引用它。类似的例子还有就是返回局部变量的指针:

int* func()
{
	int i=3;
	int *p = &i;
	return p;
}
void doNothing(int i)
{
}
int main()
{
	
	int *p = func();
	doNothing(1); // 再次注释看看区别在哪
	cout<<*p<<endl;
	return 0;
}

这个程序的结果跟上面的程序结果一样,  但我们调用 doNothing 函数, 然后输出 *p 的 结果就是个垃圾值。 如果我们注释掉doNothing 函数, *p 的输出结果还是3.

 


 

下面看一个书中的例子:

class Rational
{
private:
    int numerator;  // 分子
    int denominator;  // 分母
public:
    Rational():numerator(0), denominator(1){}
    friend const Rational& operator* (const Rational& r1, const Rational& r2){…}
};

 

我们可以设想一下实现:

friend const Rational& operator* (const Rational& r1, const Rational& r2)
{
        Rational temp;
        temp.numerator = r1.numerator * r2.numerator;
        temp.denominator = r1.denominator * r2.denominator;
        return temp;
}

这是一种最容易想到的实现方式,在栈上创建对象temp,分子、分母分别相乘后,将之返回。

但仔细再想想,调用函数真的能收到这个temp吗?它是在operator*函数调用时的栈上产生的,当这个函数调用结束后,栈上的temp也会被pop掉,换言之,temp的生命周期仅存在于operator*函数内,离开这个函数,返回的引用将指向一个已经不在的对象!

 

对此,VS编译器已经给出了warning,如下:

“warning C4172: returning address of local variable or temporary”

千万不能忽略它。

 

那既然栈上创建对象不行,还可以在堆上创建嘛(new出来的都是在堆上创建的),于是我们有:
 

friend const Rational& operator* (const Rational& r1, const Rational& r2)
{
    Rational *temp = new Rational();
    temp->numerator = r1.numerator * r2.numerator;
    temp->denominator = r1.denominator * r2.denominator;
    return *temp;
}

这下VS编译器没有warning了,之前在资源管理那部分说过,new和delete要配对,这里只有new,那delete了?delete肯定不能在这个函数里面做,只能放在外面,这样new和delete实际位于两个不同的模块中了,程序员很容易忘记回收,而且给维护也带来困难,所以这绝对不是一种好的解决方案。书上举了一个例子,比如:

Rational w, x, y, z;
 w = x * y * z;

连乘操作会有两次new的过程,我们很难取得operator*返回的reference背后隐藏的那个指针。

当然,如果把new换成auto_ptr或者是shared_ptr,这种资源泄露的问题就可以避免。

 

栈上创建的临时对象不能传入主调模块,堆上创建的对象就要考虑资源管理的难题,还有其他解决方法吗?

我们还有static对象可以用,static对象位于全局静态区,它的生命周期与这个程序的生命周期是相同的,所以不用担心它会像栈对象那样很快消失掉,也不用担心它会像堆对象那样有资源泄露的危险。可以像这样写:

friend const Rational& operator* (const Rational& r1, const Rational& r2)
{
    static Rational temp;
    temp.numerator = r1.numerator * r2.numerator;
    temp.denominator = r1.denominator * r2.denominator;
    return temp;
}

这样写编译器同样不会报错,但考虑一下这样的式子:

Rational r1, r2, r3;
if(r1 * r2 == r1 * r3){…}

if条件恒为真, 不管它们的值是什么。 因为static 对象被所有类对象所共享。

 

既然一个static对象不行,那弄一个static数组?把r1*r2的值放在static数组的一个元素里,而把r1*r3放在static数组的另一个元素里?仔细想想就知道这个想法是多么的天马行空。

 

一个必须返回新对象的正确写法是去掉引用,就这么简单!

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

该让编译器复制的时候就要放手去复制,就像花钱买东西一样,必须花的终究是要花的。

 

总结:

绝不要返回一个指向局部对象的指针或者引用,也不要返回指向分配在堆上的对象,也不能返回指向局部 static 对象的 引用 或 指针。

 


条款22. 将变量声明为private


为什么要将成员变量声明为private而不是public呢:

  •  能够提供语法一致性,如果变量都是private,那么用户只能通过函数来获得这个变量,而不用考虑在类外访问时要不要加“.”或者“->”  访问时要不要加圆括号。
  • 使用函数,可以对变量进行精确地控制:有的变量不许访问,有的只读,有的可以读写,甚至是可以“只写”。而且在函数中,可以处理用户输入的不合理的参数。
  • 封装性。封装性意味着,当你有新的想法需要修改时,只需要改变函数内部的实现细节。只要函数的接口没有改变,那么所有使用这个函数的代码就不需要做改变。而假如你使用的数据,那么整个代码的修改量就会大很多。简而言之,public意味着不封装,所有public下的东西都是别人用的,你不能轻易修改它们。
  • 而对于protected,前两点依然适用。而对于第三点,可以从另一个角度来说明:如果public变量被取消那么所有使用它的代码都会被取消。对于protected,则破坏的是他的派生类。这都是无法衡量的。而如果private变量被取消,我们只要修改本类中调用这个变量的函数就行了。

 

为什么把成员设置为 proteted不行呢?

  • 如果不存在继承关系,protected的作用与private等同,除了本类之外,其他类或者类外都不能访问,但如果存在继承关系时,protected标识的成员变量,在它的子类中仍可以直接访问,所以封装性就会受到冲击。这时如果更改父类的功能,相应子类涉及到的代码也要修改,这就麻烦了。而如果使用private标识,则根据规则,所有private标识的变量或者函数根本不被继承的,这样就可以保护父类的封装性了,仍可以在子类中通过父类的成员函数进行访问。

最后总结:

  • 将数据成员声明为私有。它在语法上为客户端提供了对数据的统一访问,提供了细粒度的访问控制,允许执行不变量,并提供了类作者实现的灵活性。
  • protected的封装性并不比public强、

 


*条款23:宁以non-member、non-frined替换member函数


类中可能存在一系列函数用来处理一系列操作,而这一系列函数可以放在类中某一成员函数中一起执行,但是也可以放在一个non-member non-frined 函数中一起执行。那么从封装性上来讲哪一个好呢?

答案是 non-member、non-frined函数,这是因为成员函数还可以访问类中的私有成员,但是非non-member、non-frined 函数连私有成员都访问不了,所以non-member、non-frined函数的封装性更好。

现在例举书上的例子:

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

class WebBrower
{
public:
    void ClearCach();
    void ClearHistory();
    void RemoveCookies();
};

现在呢,你想同时执行这些操作,现在需要将这三个函数打包成一个函数,这个函数执行所有的清理工作,那是将这个清理函数放在类内呢,还是把他放在类外呢?

如果放在类内,那就像这样:

class WebBrower
{
    …
    void ClearEverything()
    {
        ClearCach();
        ClearHistory();
        RemoveCookies();
    }
    …
};

如果放在类外,那就像这样:

void ClearWebBrowser(WebBrower& w)
{
    w.ClearCach();
    w.ClearHistory();
    w.RemoveCookies();
}

上面两种做法,哪种比较好呢?答案是 non-member、non-frined函数比较好。

 

为了区分开,我们把在类内的总清除函数称之为ClearEverything,而把类外的总清除函数称之为ClearWebBrower。ClearEverything对封装性的冲击更大,因为它位于类内,这意味着它除了访问这三个公有函数外,还可以访问到类内的私有成员,是的,你也许会说现在这个函数里面只有三句话,但随着功能的扩充,随着程序员的更替,这个函数的内容很可能会逐渐“丰富”起来。而越“丰富”说明封装性就会越差,一旦功能发生变更,改动的地方就会很大。

 

再回过头来看看类外的实现,在ClearWebBrowser()里面,是通过传入WebBrower的形参对象来实现对类内公有函数的访问的,在这个函数里面是绝对不会访问到私有成员变量。因此,ClearWebBrowser的封装性优于类内的ClearEverything。

 

但这里有个地方需要注意,ClearWebBrower要是类的 non-member、non-frined 函数,上面的叙述才有意义,因为类的友元函数与类内成员函数对封装性的冲击程度是相当的。

看到这里,你也许会争辩,把这个总清除的功能函数放在类外,就会割离与类内的关联,逻辑上看,这个函数就是类内函数的组合,放在类外会降低类的内聚性。

为此,书上提供了命名空间的解决方案,事实上,这个方案也是C++标准程序库的组织方式,好好学习这种用法很有必要!像这样:

namespace WebBrowserStuff
{
   
    class WebBrowser();
    void ClearWebBrowser(WebBrowser& w);
   
}

 

总结:宁可拿 non-member non-friend 函数替换member函数,这样做可以提高封装性、封装灵活性和功能可扩展性。

 


条款24: 若所有参数皆需类型转换, 请为此采用 non-member 函数


我们在前面的条款提到过, 应该避免某一个类有隐式构造函数。 但是呢,也有例外的时候 ,比如说我们想建立数值类型的class。 假设我们用一个class 表示有理数类, 该类呢允许 整数 到有理数的隐式转换。 举书上的例子:

class Rational
{
private:
	int numerator;
	int denominator;
public:
	Rational(int n = 2, int d = 1) : numerator(n), denominator(d) {}

	int getNumerator() const { return numerator; }
	int getDenominator() const { return denominator; }

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

注意这个构造函数前面没有加explicit关键字,这意味着允许隐式转换。getNumerator() 和 getDenominator()是用来获取私有成员的值的. 

当我们将 Rational 对象 跟一个整数相乘时,第二个调用发生错误,这为什么会发生错误?  乘法不是支持交换律吗?

int main()
{
	Rational oneEighth(1, 8);
	Rational oneHalf(1, 2);

	Rational result = oneHalf * 2; // 正确
	result = 2 * oneHalf; //错误
	system("pause");
	return 0;
}

当我们 指向  “ Rational result = oneHalf * 2 ” 该代码的时候,编译器首先在类中查找,不知道你们还记得吗,  一个类中的每一个成员函数都有一个隐含的 this 指针, 该指针指向调用该函数的对象。所以说当执行 “ operator* ()” 函数时, “ this 指针 ” 指向 oneHalf  对象, 实参2 传递 “ operator* ()” 函数(此时会发生隐式转换)。

 

那么当我们执行到 “ result = 2 * oneHalf ” 代码的时候, “ 2 ” 只是一个整型数,并非一个类, 所以它并没有 “ this ” 指针。 而且 “ this 指针”  指向本类对象是const的,即 “ *this 指针” 指向的那个对象不能被改变。 所以说 “ 2 ” 不能被隐式转换。

 

那么编译器可能会在类外查找 non_member 函数 ( non_member 函数 指的是 普通函数  和  friend 函数), 但是在本例中并没有实现。 所以说查找失败。

 

那么如何 执行 “ result = 2 * oneHalf ” 该代码时正确呢?  书上建议的是把 “ operator* ()” 函数 实现为 non_member 函数 ( non_member 函数 指的是 普通函数  和  friend 函数, 书上建议使用 普通的 非成员函数,原因后面讲),从而允许编译器对所有形参执行隐式类型转换: 

class Rational
{
private:
	int numerator;
	int denominator;
public:
	Rational(int n = 2, int d = 1) : numerator(n), denominator(d) {}

	int getNumerator() const { return numerator; }
	int getDenominator() const { return denominator; }
};

// 实现为 普通的函数
const Rational operator* (const Rational& r1, const Rational& r2)
 {
	     return Rational(r1.getNumerator() * r2.getNumerator(), r1.getDenominator() * r2.getDenominator());
 }

int main()
{
	Rational oneEighth(10, 20);
	Rational oneHalf(10, 30);

	Rational result = oneHalf * 2; // 正确
	result = 2 * oneHalf; //正确
	
	system("pause");
	return 0;
}

这样写正确了。

 

那么为什么 “ operator* ()” 函数不实现为 friend 函数呢?

  • 因为 friend 函数破坏了封装。这就引出了一个重要的结论: 成员函数的对立面是非成员函数,而不是friend函数。 太多的c++程序员认为,如果一个函数与一个类相关,不应该是一个成员函数(例如,由于需要对所有参数进行类型转换),那么它应该是一个 friend 函数。这个例子证明了这种推理是有缺陷的。无论何时,能避免使用friend 函数,就应该避免之。但事实仍然是,一个功能如果不应该是成员 函数,并不意味着它应该是一个friend 函数。

总结 : 如果需要对成员函数的所有参数进行类型转换(包括this指针指向的那个对象),则该函数必须是非成员函数。

 


条款26:尽可能延后变量定义式的出现时间


当你在某一个作用域中定义一个类类型变量时,当程序进入这个程序块时到达这个变量的定义式才会被创建,那么此时你需要付出构造成本; 当这个变量离开其作用域时,还要付出销毁成本。 有时可能这个变量最终并未使用, 可能仍然需要耗费构造、析构成本, 所以说应该尽可能的避免这样的情况。

 

比如下面这个函数,把密码转换为“ * “:

string encrytPassword(const string password)
{
	using namespace std;
	string::size_type miniLength = 5;
	string encrypted;

	if (password.length() < miniLength)
	{
		throw logic_error(" password is too short");
	}

	for (string::size_type i = 0; i != password.size(); ++i)
	{
		encrypted.append("*");
	}

	return encrypted ;
}

我们规定了密码长度不能小于5,如果小于5则会抛出异常。仔细研究就会发现,假如密码长度真的小于5,就不会执行 if 后面的部分,所以string encrypted 就不会被使用了。那么前面定义的工作也就白做了。况且这个定义还调用了string的默认构造函数,如果说抛出了异常, encrypted  不一定会被销毁。所以,我们应该延后这个变量出现的时间,把它放在for 语句的前面 。

string encrytPassword(const string password)
{
	using namespace std;
	string::size_type miniLength = 4;
	
	if (password.length() < miniLength)
	{
		throw logic_error(" password is too short");
	}

	string encryted; // 定义在这里
	for (string::size_type i = 0; i != password.size(); ++i)
	{
		encryted.append("*");
	}
	return encryted;
}

 

其实,这样做也不是最好的,因为 encrypted 是在没有任何初始化参数的情况下定义的。这意味着会调用它的默认构造函数。 在条款4 中说过,“ 通过默认构造函数构造出的对象然后进行赋值 ” 比 “ 直接在构造对象时指定初值 ” 效率差。 那么这个原则也适用于此。 所以说最好的作法是直接用理想的初值初始化这个变量,从而跳过毫无意义的默认构造函数:

string encrytPassword(const string password)
{
	using namespace std;
	string::size_type miniLength = 4;
	
	if (password.length() < miniLength)
	{
		throw logic_error(" password is too short");
	}

	string encryted = “ huangchengtao ”; // 定义并初始化
	for (string::size_type i = 0; i != password.size(); ++i)
	{
		encryted.append("*");
	}
	return encryted;
}

所以说我们应该将变量的定义推迟到必须使用该变量之前,还应该尝试将定义推迟到该变量有初始化参数之后。 通过这样做,可以避免构造和销毁不需要的对象,并避免不必要的默认构造。此外 , 而且,一个初值明确的变量能大大提高程序的可读性。

 

本书还提出一种情况:是把变量定义放在for循环中,还是放在for循环之外?

做法A:

for (int i = 0; i < 5; ++i)
{
	Test tt;
	if (t.getVal() == i)
	{
		// Statemets
	}
}


做法B:

Test tt;
for (int i = 0; i < 5; ++i)
{
	if (t1.getVal() == i)
	{
		// Statemets
	}
}

做法A: 一个构造函数 + 1 个析构函数+ n个赋值操作

做法B: n 个构造函数 + n 个析构函数

 

二者区别在于:首先,一般而言放在循环体外代码效率更高,因为这样你只需要做一次构造与析构。而在循环体内你得做很多次。其次,在循环体内定义的变量的作用域比较小,易于代码的维护。这取决于你代码所关心的重点:是效率还是安全?

 

总结:  在变量使用前定义 ,并且初始化。它提高了程序清晰度并提高了程序效率。

 


条款27:尽量少使用转型动作


本条款开始部分就讲了在C++ 语言中早期版本中的两种显式转换,以及 static_cast、reinterpret_cast、static_cast、dynamic_cast 这些新式转换。  如果你想把这几种转换了解清楚, 可以看C++ primer 这本书, 它们都在C++ primer 第144页都有详细的介绍, 只有 dynamic_cast  在 730页。

dynamic_cast 效率很低,能不用就不用吧。通常,只有当你想在一个认定为派生类对象的身上执行派生类操作,而你却只有一个基类指针或者引用时,才需要dynamic_cast:


class Base
{
public:
	Base(int i = 0):bVal(i){cout<<"基类构造函数"<<endl;}
/*	virtual void say()
	{
		
		cout<<"基类函数"<<endl;
	}*/
	virtual void fun(){}
private:
	int bVal;
};
 
 
 
class Drived:public Base
{
public:
	Drived(int i = 10, int j = 5):Base(i),dVal(j){cout<<"派生类构造函数"<<endl;}
	void say()
	{
		cout<<"派生函数"<<endl;
	}
private:
	int dVal;
};
int main()
{
	Base *pd = new Drived;
	Drived* pd1 = dynamic_cast<Drived*>(pd); // 把一个基类指针转换为派生类指针
	pd1->say();
	return 0;
}

注意,这个转化这里要求基类必须有虚函数。

感觉没什么可写的,最后总结一下:

  •  如果可以,尽量避免转型,特别是在注重效率的代码中避免 使用dynamic_cast。如果某个代码需要转型动作,看看有没有其它方式来替代转型动作。
  • 当需要转型时,尝试将其隐藏在函数内部。然后,客户可以调用该函数,而不是将强制转换放入自己的代码中。
  • 宁可使用C++风格的新式转型,少用旧风格转型,因为前者很容易辨识出来,而且它们更具体的说明各个转型的作用。

 


条款28:避免返回handles指向对象内部成分


举书上的例子:

//点类
class Point
{
public:

	Point(int xVal, int yVal) :x(xVal), y(yVal) {}
	~Point() {}
	void setX(int newX) { x = newX; }

	//返回X坐标,以后测试用
	int getX()const { return x; }
	void setY(int newY) { y = newY; }
private:
	int x;
	int y;
};

//矩形
struct RectData
{
	RectData(const Point& p1, const Point& p2) :ulhc(p1), lrhc(p2) {}
	Point ulhc;//坐上
	Point lrhc;//右下
};

//矩形类
class Rectangle
{
public:
	Rectangle(RectData data) :pData(new RectData(data)) {}
	 Point& upperLeft()const { return pData->ulhc; }
	 Point& lowerRight()const { return pData->lrhc; }

private:
	std::shared_ptr<RectData> pData;
};

这两个函数都返回对私有内部数据的引用——调用者可以使用这些引用来修改该内部数据,虽然我们声明 upperLeft() 和 lowerRight() 都是const函数,,例如:

int main()
{
	Point coord1(0, 0);
	Point coord2(100, 100);
	RectData data(coord1, coord1);
	const Rectangle rec(data);
	rec.upperLeft().setX(50);  // 这里可以修改rec 对象的数据成员的值

	system("pause");
	return 0;
}

之所以可以 修改rec 对象的数据成员的值是因为 upperLeft() 和 lowerRight()  这两个函数返回普通的引用,如果它们返回 指针或者迭代器结果都是一样的,原因也相同。 引用、指针和迭代器都是handles ( 号码牌,用来获取某个对象)。 如果返回的 handles 刚好指向的是 “ 对象的数据成员的值 ”,那么 该 “ 对象的数据成员的值 ” 的值就有可能被修改。 甚至出现这种情况 —— “虽然调用const成员函数却造成对象状态被更改” , 上述的例子就是。

比如这里有一个学生类:

class Student
{
private:
    int ID;
    string name;

public:
    string& getName()
    {
        return name;
    }
};

这是一个学生的类,类里面有两个成员变量,一个是学生ID,用整数表示,另一个是姓名,用string表示。有一个公有的方 getName(),获得学生的名字. 根据条款20所说的,使用引用可以防止资源不必要地拷贝,那么在返回值这边就用string&。但现在问题来了,这个函数只是想返回学生的姓名,并不想用户对之进行修改,但返回引用却提供了这样的一个接口,如:

int main()
{
    Student s;
    s.GetName() = "Jerry";  // 这里就把 Student 对象数据成员的值给修改了
    cout << s.GetName() << endl;

   
	system("pause");
	return 0;
}

这个例子刚好就说明了 “ 如果返回的 handles 刚好指向的是 “ 对象的数据成员的值 ”,那么 该 “ 对象的数据成员的值 ” 的值就有可能被修改。”

 

 本书中还提出,也不要返回 protected 和 private 区域的 成员函数的 handles。

 

那回到原来的例子, 如何禁止返回的 handles 不被修改呢?只要对它们的返回类型加上const就可以了:

const Point& upperLeft()const{return pData->ulhc;}
const Point& lowerRight()const{return pData->lrhc;}

有了这样的改变, 客户只可以读取 它们返回的引用,而不能修改它们。

 

即使这样,upperLeft() 和 lowerRight()   函数还是返回了代表对象内部的handles ,它也会产生其他方面的问题,比如:悬空句柄——这个句柄所指的东西并不存在, 最常见的原因就是该函数的返回值。举一个例子:

//一个GUI对象
class GUIObject
{
public:
	GUIObject(Rectangle r) {}
	//返回一个指定大小的矩形框
	const Rectangle getRec()const
	{
		Point coord1(50, 50);
		Point coord2(200, 200);
		RectData data(coord1, coord2);
		const Rectangle rec(data);
		return rec;
	}

};

//返回obj的外框
const Rectangle boundingBox(const GUIObject& obj)
{
	return obj.getRec();
}

int main()
{
	Point coord1(10, 10);
	Point coord2(100, 100);
	RectData data(coord1, coord2);

	const Rectangle rec(data);

	GUIObject obj(rec);

	//一个GUI对象指针
	GUIObject* pgo = &obj;


	//获取它的左上角点
	const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
	cout << pUpperLeft->getX();


	system("pause");
	return 0;
}

boundingBox返回一个新的Rectangle对象。然后调用upperLeft函数返回它的左上角。但是,当这条语句结束以后,boundingBox的返回值就会被销毁了,导致它的Points被析构,所以pUpperLeft 就成了一个悬空句柄(不代表任何实际存在的对象)。由此可见,返回一个指向对象内部成分的句柄,是一项危险的事情,因为对象可能在任何时候被析构,此后,这个句柄就成了悬空句柄了。
 

这就是为什么函数如果 “返回一个handle代表对象内部成分” 总是危险的原因,不管这个 handles 返回的是 指针或者引用或者迭代器, 也不论这个handle 是不是const的, 也不论 那个返回handles 的成员函数是否为const成员函数。 关键的是: 只要这个handles 如果被传递出去,您将 面临handles比它所引用的对象活得更久的风险。

 

当然了,这也不意味着绝不可以让成员函数返回handles。

 

好了,总结一下:

避免返回handles(包括reference、指针、迭代器)指向对象内部,遵守这个条款可增加封装性,帮助 const 成员函数的行为像个const。 这样也可以避免发生dangling handles的可能性降 最低。如果有必要必须要返回handles,在编写代码时就一定要注意对象和传出handle的生命周期。

 


条款30:透彻了解inlining的里里外外


将函数指定为内联函数,通常程序会在函数调用的地方直接插入函数体中代码,例如:

inline const string &shorter(const string &s1, const string &s2)
{
	return s1.size() <= s2.size() ? s1 : s2;
}

int main()
{
	std::string s1("huang");
	std::string s2("cheng");
	cout << shorter(s1,s2) << endl;
	system("pause");
	return 0;
}

例如上述程序我们把 shorter 函数定义为内联函数,那么将在编译过程中展开成类似于下面的形式:

cout<< ( s1.size() <= s2.size() ? s1 : s2 ) <<endl;

从而消除了 shorter 函数在运行时的开销。


上面这是一种以空间换时间的做法。把每一次调用都用本体替换,即可以使函数的执行效率提高,也可以节省函数调用的成本,因为函数调用需要将之前的参数以栈的形式保存起来,调用结束后又要从栈中弹出那些参数。但是使用过多的内联函数可能会使代码膨胀。

所以一般来说, 内联函数一般用于 优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数, 而且一个75行的函数也不可能在调用点内敛地展开。

 

但要注意的是: 使用inline 关键字只是向编译器发出的一个请求, 编译器可以选择忽略这个请求。 它不是强制的命令。

 

如果说想内联一个普通的函数直接在其返回类型前面加上关键字inline 关键字即可,那么任何在类中定义的函数(包括friend 函数)自动隐式地成为内联函数,也可以使用inline 关键字放在类外定义函数前面使之成为内联函数,但是必须使函数体和声明结合在一起, 否则, 编译器只将这个函数视为普通成员函数。 例如:

inline int ff()  // 把一个普通的函数定义为内联函数
{


}

class Rect
{
public:
	int width();
    int height() // 在类中定义的函数都隐式成为内联函数,除了虚函数,因为内联(代码展开)发生在编译期,而虚函数的行为是在运行期决定的
  { 

  }
};

inline int Rect::width() // 在定义成员函数时,在其返回类型上添加 inline 关键字,使之成为内联函数
{
 
}

还可以这样定义内联函数:

class Rect
{
	inline int width();
};
 int Rect::width()
{
 
}

和其他函数不一样, 内联函数可以在程序中多次定义。毕竞, 编译器要想在编译期间展开函数仅有函数声明是不够的, 为了用被调用函数的主体替换函数调用,编译器必须知道函数的定义。不过, 对于某个给定的内联函数来说, 它的多个定义必须完全一致。基于这个原因, 内联函数通常定义在头文件中。—— 这条是引用了C++ primer 中的, 内联函数在C++ primer 第五版 中 第 214 页有简单的介绍。

 

书上还提出   模板( 包括函数模板 和 类模板成员函数)通常都被定义在头文件中,因为编译器需要知道模板的定义,以便在使用它时实例化它。( 同样,这不是普遍的。一些构建环境在链接期间执行模板实例化。然而,编译时实例化更为常见 )。但是呢,这也不是绝对的。

模板实例化与内联无关。如果你正在编写一个模板,并且你认为从此模板实例化的所有函数都应该内联,那么就内联地声明该模板;树中的 std::max 代码就是这样做的。但是如果你编写的template 不需要要求实例化时的每一个函数都是内联的, 就应该避免将这个template 声明为inline (不管是隐式的还是显式的)的。内联是有成本的,您不希望在没有预先考虑的情况下产生这些成本。我们已经提到了内联如何会导致代码膨胀。  说明: 关于这点内容在C++ primer 第五版 第582 页 有介绍。

 

刚才我们说了 “ inline 关键字只是向编译器发出的一个请求, 编译器可以选择忽略这个请求。 它不是强制的命令。 ”, 那么它啥时候会忽略呢:

  • 一般在太过复杂的函数(例如 循环和递归)。

 

虚函数(virtual)可以是内联函数(inline)吗?

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
  • 内联是在编译器建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

虚函数内联的使用程序示例:

#include <iostream>  
using namespace std;
class Base
{
public:
	inline virtual void who()
	{
		cout << "I am Base\n";
	}
	virtual ~Base() {}
};
class Derived : public Base
{
public:
	inline void who()  // 不写inline时隐式内联
	{
		cout << "I am Derived\n";
	}
};

int main()
{
	// 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,编译期间就能确定了,
       //所以它可以是内联的,但最终是否内联取决于编译器。 
	Base b;
	b.who();

	// 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,所以不能为内联。  
	Base *ptr = new Derived();
	ptr->who();

	// 因为Base有虚析构函数(virtual ~Base() {}),所以 delete 时,会先调用派生类(Derived)析构函数,再调用基类(Base)析构函数,防止内存泄漏。
	delete ptr;
	ptr = nullptr;

	system("pause");
	return 0;
} 

这个内容是参考了这里的,需要详情,请点击此链接:http://www.cs.technion.ac.il/users/yechiel/c++-faq/inline-virtuals.html

 

有时编译器会为内联函数生成一个函数体,即使他们非常愿意内联该函数。比如:使用某个内联函数的地址(指向函数的指针, 意思就是这个指针是指向函数的, 即指针存储的是函数的首地址。):

 inline string lengthCompare(const string &s1, const string &s2)
{
    return s1.size() < s2.size() ? s2 : s1;
}
int main()
{
   
     string(*pf)(const string &s3, const string &s4) = lengthCompare; //定义函数指针pf
 
    string s3 = (*pf)(s1, s2);  //调用lengthCompare函数 ,  //string s4 = pf(s1,s2);  一个等价的调用

    string s5=lengthCompare(s1,s2); 
    system("pause");
    return 0; 
}

那么当 调用 该 “  string s3 = (*pf)(s1, s2);   ” 代码时这个调用代码可能不会内联,因为它是通过一个函数指针调用的。

 

虽然有时候构造函数和析构函数虽然“看”似简单,但编译器会在背后做很多事情,比如一个空的构造函数里面会由编译器对该类的成员作默认初始化。如果将之inline,那么当创建该类对象时, 构造函数会在创建该类对象时内联地展开; 假如说这个类很多个数据成员需要默认初始化, 那么在 “ 创建该类对象时内联地展开 ”, 无疑会使代码膨胀,执行效率变低。 析构函数也类似。 所以说我们不要将构造函数和析构函数使之为inline 函数, 即使它们什么也不做。

 

要慎用inline,是因为一旦编译器真的将之inline了,那么这个inline函数一旦被修改,整个程序都需要重新编译,而如果这个函数不是inline的,那么只要重新连接就好。另外,一些调试器对inline函数的支持也是有限的。

作者认为,“一开始先不要将任何函数声明为inline”,经测试,确实发现某个函数的inline要比不对之inline的性能提升很多,才对之inline。在大多数情况下,inline并不是程序的瓶颈,真正的精力应该放在改善一些算法的修改上,以及反复调用的代码研究上,它们往往才是耗时的瓶颈所在。

 

下面说下编译器对 inline 函数的处理步骤:

  • 将 inline 函数体复制到 inline 函数调用点处;
  • 为所用 inline 函数中的局部变量分配内存空间;
  • 将 inline 函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  • 如果 inline 函数有多个返回点,将其转变为 inline 函数代码块末尾的分支(使用 GOTO)。

 

inline 的优缺点:

优点

  • 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  • 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  • 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  • 内联函数在运行时可调试,而宏定义不可以。

缺点

  • 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  • inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  • 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

 

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值