一、函数返回值类型的选择
1.返回值为指针类型
优点:
缺点:
(1) 悬空指针 ---- 运行崩溃:
(2) 悬空指针 ---- 未定义行为
(3) 避免悬空指针
2.返回值为引用类型
优点:
(1) 通过接收返回值改变传入参数值
(2) 实现链式调用
缺点:
(1) 悬空引用
(2) const + 引用返回值
二、函数传入参数类型的选择
1.传入参数为指针类型
优点:
(1) 可以修改传入参数的值
(2) 节约内存开销
缺点:
指针的有效性检查及安全性问题
2.传入参数为引用类型
优点:
缺点:
(1) 空引用和悬垂引用
(2) 注意引用参数的可变性 const
总结
通过系列对函数使用及指针引用的学习,我们了解到当函数参数是指针或引用时都可以保留更改到函数外,即函数执行完毕后对传入参数造成的影响依然存在,那指针和引用在作为函数返回值和传入参数时又有哪些区别,不同情境下又该如何选择使用指针还是引用能有更小的系统开销和更高的安全性与稳定性。
- 可以返回指向动态分配的内存的指针,避免了返回大型对象的开销。
- 可以通过返回指针来实现多值返回。
- 允许返回空指针,表示没有找到结果或发生错误。
其中(1)、(3)的实例在之前的博客文章有提及,传送门:http://t.csdn.cn/2gxKl
简单来讲,指向动态分配内存的指针即为动态数组的实现,实现多值返回可以是返回类或者结构体对象:
先定义后面出现的Value类:
class Value
{
public:
Value():a(10),b(20){}
int a;
int b;
};
再定义(返回指针)函数:
Value* get_ValNum()
{
Value* v = new Value;
v->a = 100;
v->b = 200;
return v;
}
调用函数实例:
void test1()
{
const Value* v1 = get_ValNum();
cout << "a = " << v1->a << endl;
cout << "b = " << v1->b << endl;
delete v1;
}
运行结果:
这样就能通过函数返回的指针实现多值返回,使得通过访问接收函数返回值的指针变量间接的读取Value类类型指针所指向对象的类内元素。
- 需要调用者负责释放内存,容易出现内存泄漏或悬空指针的问题。
- 返回指针可能会暴露内部数据结构,破坏封装性。
正如上面的 test1() 函数中的实现内容,使用局部指针变量接收函数返回的指针,在主调函数结束时需要人工对局部指针变量进行释放,避免内存泄露,但是我们试着继续访问delete后的指针变量会得到运行时奔溃或未定义行为:
void test1()
{
const Value* v1 = get_ValNum();
cout << "a = " << v1->a << endl;
cout << "b = " << v1->b << endl;
delete v1;
cout << v1->a << endl; // 错误访问(编译不报错)
}
运行报错:
当然有时发现这样访问 delete 掉的对象仍然可以访问对象内部元素或函数,在某些情况下,已删除的对象的内存可能没有被立即覆盖或修改,因此似乎仍然可以访问其内部成员。但是,这种行为是未定义的,可能会导致不可预测的结果。在其他情况下,已删除对象的内存可能已被修改或重新分配给其他对象,因此访问其内部成员会产生错误的值。
未定义行为案例如下:
我们稍微丰富 Value 类的内容(添加两个成员函数):
class Value
{
public:
Value():a(10),b(20){}
void printClassName()const
{
cout << "Value" << endl;
}
void print_a_b() const
{
cout << "a = " << a << "\t" << "b = " << b << endl;
}
int a;
int b;
};
调用部分:
void test1()
{
const Value* v1 = get_ValNum();
delete v1;
v1->printClassName(); // 注意此处调用函数时指针已经被释放
}
运行结果:
这里程序并没有想象中奔溃报错,反而正常输出了结果,这里即是第一种情况:已删除的对象的内存可能没有被立即覆盖或修改,因此似乎仍然可以访问其内部成员。
下面再来访问另一个函数试试:
void test1()
{
const Value* v1 = get_ValNum();
delete v1;
v1->print_a_b(); // 注意此处调用函数时指针已经被释放
}
运行报错:
明明访问的都是类内函数,为何到这又不行了?
其实仔细观察不难发现,上面 printClassName() 函数内部并没有调用类内成员变量或其他函数,不需要函数内部隐式调用this指针来访问其他成员,所以上面并未报错,但实质上也属于未定义行为。这里print_a_b()函数内部调用了a,b成员变量,所以在通过this指针访问成员变量是因为内存已经释放,所以会出现this找不到a,b情况,即出现了运行报错访问权限冲突。
为了避免上述运行报错或是出现未定义行为,我们需要一种方法解决delete后造成的指针悬空问题,实现方法:
delete v1;
v1 = nullptr; // 释放后随手置空
只需要对delete后的指针变量进行置空操作,即可避免后续错误访问时出现无厘头错误,试着置空后访问:
调用函数如下:
void test1()
{
const Value* v1 = get_ValNum();
delete v1;
v1 = nullptr;
cout << v1->a << endl;
}
运行报错:
这里显示变为更加明朗的nullptr空指针报错,便于我们找错分析。
再试着访问其他成员函数:
void test1()
{
const Value* v1 = get_ValNum();
delete v1;
v1 = nullptr;
// 注意此处调用函数时指针已经被释放
v1->print_a_b();
}
运行报错:
访问刚才看似正常调用的函数:
void test1()
{
const Value* v1 = get_ValNum();
delete v1;
v1 = nullptr;
// 注意此处调用函数时指针已经被释放
v1->printClassName();
}
运行结果:
还是看似正常输出,未定义行为依然存在,所以将其置空仍然解决不了这个问题,有两种解决方法:
避免删除对象后继续使用指针:在删除对象后,避免对指针进行任何操作,包括访问成员函数或解引用。确保在删除对象后不再使用指针,以避免未定义行为。或者后续使用时对指针进行非空检查,例如使用 if(v1) 检查指针是否为空再执行相应操作。
使用智能指针:使用智能指针(如
std::shared_ptr
或std::unique_ptr
)可以更安全地管理内存。智能指针会在对象不再需要时自动释放内存,并在指针被销毁后将其置空,从而避免访问已删除对象。
以上两种方法终究还是在避免delete后的对象继续调用,所以要避免以上所有报错和未定义行为最好的办法还是程序员尽量实现避免释放后继续调用,其他方法都是退而求其次,作为保护程序正常运行的第二道防线。通过在 delete
后立即将指针置空,并在访问指针之前进行有效性检查,可以更好地避免访问已删除对象的内部成员导致的未定义行为。
- 可以直接修改原始对象的值,对原始数据进行修改。
- 可以实现链式调用,提高代码的可读性和简洁性。
int& getMax(int& a, int& b)
{
return a > b ? a : b; // 返回a,b中较大的值,若相等返回b
}
调用以上函数实例:
void test2()
{
int num1 = 10;
int num2 = 20;
int& max = getMax(num1, num2); // 注意max定义为引用类型,相当于给函数返回值起别名
max = 100; // 通过改变max的值间接改动num
cout << "num1 = " << num1 << endl;
cout << "num2 = " << num2 << endl;
}
运行结果:
我们看到当改变max的值时,引起了num2的同步变化,因为在 getMax() 函数中num1和num2以引用形式传入函数,在函数结束时,这里将num2的引用做函数的返回值传给函数外部max变量,max变量类型为int&,所以将max与num2进行了绑定以实现同步变化。
以下给出一个比较偏实用的例子,利用引用类型做返回值,通过重载左移运算符实现链式调用:
class Person
{
public:
Person(const int age, const string& name) :m_age(age), m_name(name) {}
friend ostream& operator<<(ostream& out, const Person& p); // 重载左移运算符 声明友元函数
int m_age;
string m_name;
};
ostream& operator<<(ostream& out, const Person& p)
{
out << "name = " << p.m_name << "\t" << "age = " << p.m_age << endl;
return out;
}
调用以上重载运算符:
void test3()
{
const Person p1(24, "张三");
const Person p2(19, "李四");
const Person p3(22, "王五");
cout << p1 << p2 << p3 << endl;
}
运行结果:
这样通过简单的利用返回引用类型的重载运算符,即可实现链式调用,使得编写代码效率更高,意图更加清晰更具可读性。
- 返回的引用可能会指向临时对象,容易出现悬空引用的问题。
- 返回引用可能会破坏封装性,暴露内部数据结构。
首先,给出一个基础类:
class A
{
public:
A():m_val(10){}
explicit A(const int val):m_val(val){}
void setVal(const int val)
{
this->m_val = val;
}
const int& getVal() // int型较小一般返回值,这里选择返回引用也可
{
return m_val;
}
private:
int m_val;
};
接着来看一个出现悬空引用的函数:
A& get_A()
{
A temp_a;
return temp_a;
}
调用函数:
void test4()
{
A& a = get_A(); // 用 a 做出现悬空引用函数返回值的别名
a.setVal(40);
cout << a.getVal() << endl;
}
运行结果:
可以看出与我们的预设值10不符,是一个出乎意料的值。在这里 get_A() 函数返回的是一个A类型的局部变量temp_a的引用,当a接收返回值后,对a进行相关操作试图改变 get_A() 函数中局部变量temp_a的值,是不规范、不正确的,会出现未定义行为或程序崩溃。
通过把引用做函数返回值的优缺点结合后,不难看出,要合理利用函数返回值类型为引用带来的优点,需要避免返回局部变量的引用,如果想要通过返回值改变函数外的变量值,需要将变量以引用形式传入函数作参数,同时避免 const 关键字的修饰,如果出于隐蔽性和安全性考虑,避免函数返回引用造成外界对函数内部可能的不必要更改,需要在函数返回值类型前加 const 修饰即可保证返回的引用是只读的。
很多时候出于安全性考虑,单单使用引用做函数返回值虽然便利,但是存在很大的安全隐患,在对接收函数返回值的引用类型变量进行修改或其他操作时,很容易顺带把作为传入参数的变量做了修改,所以和指针一样需要const 关键字对引用在必要的时候进行修饰,以最小的权限实现函数要进行的操作。使用const修饰的引用作为函数返回值,可以确保返回的引用是只读的,不允许修改原始值,提高代码的安全性。
先来看一个示例函数:
const int& changeNum(int& num) // 返回值用 const 修饰保证返回值是只读的
{
num = 100;
return num;
}
调用函数如下:
void test5()
{
int num = 25;
const int& a = changeNum(num); // 注意这里a必须是const型,需要和返回值类型匹配
cout << "a = " << a << endl;
}
运行结果:
因为返回值为常量引用约束的原因,后续无法进行对 a 的修改,更无法通过改变 a 的值来改变函数内部或函数传入参数的值。
同样再举个常见的例子:
const string& getFullName(string& firstName, string& lastName)
{
static string fullName; // 静态变量,确保在函数返回后仍然有效
fullName = firstName + " " + lastName;
return fullName;
}
void use_getFullName()
{
string firstName = "Zou";
string lastName = "Feng";
const string& fullName = getFullName(firstName, lastName);
cout << "Full Name: " << fullName << endl;
}
运行结果:
这里我们无法通过修改 ust_getFullName() 函数中fullName的值来改变 getFullName() 函数中静态变量fullName的值,通过将函数返回值类型设置为只读,可以很好地避免误修改。
##### 传入参数设定为const引用会在后面提及 #####
- 可以修改传入参数的值:通过传递指针作为参数,函数可以修改指针所指向的对象的值,使函数能够在调用后对原始数据进行更改。
- 节约内存开销:通过传递指针作为参数,可以避免复制较大的数据结构,从而减少内存使用和传递数据的时间开销。
首先给出自增函数:
void increaseNum(int* num)
{
++(*num);
}
调用函数如下:
void test6()
{
int a = 100;
increaseNum(&a);
cout << "a = " << a << endl;
}
运行结果:
给出传入参数是指针类型的示例函数:
int getMax_vec(const vector* vec)
{
int temp = vec->at(0);
for(int i = 0;isize();++i)
{
if (vec->at(i) > temp) { temp = vec->at(i); }
}
return temp;
}
调用函数如下:
void test7()
{
const vectorv{1, 4, 2, 9, 5, 3, 7, 6};
const int max = getMax_vec(&v);
cout << "max = " << max << endl;
}
运行结果:
这里向函数传递指向大型对象的指针,避免了调用函数时复制对象的开销,直接传指针减少了内存开销。
- 需要进行指针的有效性检查:在函数内部使用指针参数时,需要注意指针是否为空,以避免访问空指针而导致程序崩溃或未定义行为。
- 可能引发指针操作的安全问题:传递指针作为参数,可能导致指针操作的安全性问题,如空指针引用、越界访问等。
void Modify_str(char* arr, const int size)
{
if (arr == nullptr) { cout << "str is nullptr" << endl; return; }
for(int i = 0;i
上面函数中需要对字符串进行修改处理,所以需要对char类型指针进行判空处理,避免直接访问空指针引发未定义行为或程序崩溃。
调用函数:
void test8()
{
char s[] = "jinitaimei";
cout << "s.size() = " << sizeof(s) << endl;
cout << "字符串s = " << s << endl;
Modify_str(s, sizeof(s)-1); // 注意size大小控制,避免产生越界
cout << "字符串s = " << s << endl;
}
运行结果:
我们看到运行结果正确,但是如果开始没有注意字符数组最后会有'\0'字符结尾(所以使用 size() 或是 sizeof() 函数对字符串求长度时,size值会比设定值大1),直接使用数组总长求结果时会出现:
因为函数内修改操作arr[i] = 'a' + i;中出现越界访问,将'\0'字符篡改,导致字符串s的结尾需要在系统中按某种规则寻找'\0'作为结尾,所以字符串修改正确结果并不能如臆想中的出现。
- 避免复制开销:通过使用引用作为函数参数,可以避免将整个对象进行复制,从而减少了内存开销和时间开销。
- 可以实现多值返回:通过引用参数,可以在函数内部修改多个值,并将修改后的结果返回给调用者。
- 支持函数链式调用:通过返回引用,可以在函数调用后继续对同一个对象进行操作,实现链式调用的效果。
其中1、2和指针作为传入参数类型相似,这里讲讲第三点实现函数链式调用:
首先给出一个类:
class String
{
private:
string m_str;
public:
String(const string& str):m_str(str){}
String& append(const string& str) // 注意这里函数返回值需要是类类型
{
m_str += str;
return *this;
}
const string& getStr()const
{
return m_str;
}
};
调用以上类操作的函数:
void test9()
{
String s("jini");
s.append("tai").append("mei"); // 连续调用两次追加函数
cout << s.getStr() << endl;
}
运行结果:
我们可以看到结果理想,当我们在 test9() 函数中调用一次 append() 函数后,其返回对象仍然是对象的类型,同时也是对象本身,append() 函数返回的是引用,允许对同一个对象进行多次操作,实现了链式调用。
- 可能引发空引用和悬垂引用问题:如果使用引用作为函数参数时,没有传递有效的对象作为实参,或者返回引用指向的对象在函数结束后不再有效,那么可能会导致空引用或悬垂引用的问题。
- 需要注意引用参数的可变性:如果函数参数是非 const 引用,那么函数内部可以修改引用所指向的对象,这可能导致意外的副作用。
第一点仍然和指针作函数传入参数相似,但还是给出说明:
int& getValue()
{
int value = 100;
return value; // 返回局部变量的引用
}
调用上面这个瘤子函数:
void test10()
{
const int& num = getValue(); // num 作为getValue()函数的返回值别名
cout << "num = " << num << endl; // 可能输出垃圾值或导致程序崩溃
}
运行结果:
在上述示例中,getValue() 函数返回了局部变量 value 的引用,但在函数结束后,value 对象已经销毁,所以引用 int& num 成为悬垂引用,固然不会输出理想值。
首先给出改变引用类型变量值的函数:
void modifyValue(int& value)
{
value = 20;
}
调用函数:
void test11()
{
const int a = 100;
modifyValue(const_cast(a)); // 非const引用修改const对象,导致未定义行为
cout << "a = " << a << endl;
}
运行结果:
虽然 modifyValue() 函数接受非const引用作为参数,但是使用const_cast对a进行了强制类型转换,将 const int 转换为非 const int 引用。这样做虽然在语法上是合法的,但实际上违反了const的语义约束,即修改了一个被声明为const的对象。
我们可以看到输出结果a的值并没有发生改变,这种情况发生的原因是,const int 对象a在编译时被分配了只读的存储空间,并且编译器对其进行了优化,将其视为常量。因此,尽管 modifyValue() 函数修改了引用参数的值,但这个修改不会影响到a自身的值。这种情况下,编译器可能会忽略掉对a的修改,并将其视为无效操作。
使用const修饰的引用作为函数传入参数,可以确保函数内部不会修改该参数的值,提高代码的可读性和安全性。
以下给出案例:
首先给出打印vector容器的函数:
void printVec(const vector& v)
{
for(auto& elem:v)
{
cout << elem << " ";
}
cout << endl;
}
调用函数:
void test12()
{
vector vec{1, 2, 3, 4, 5, 6, 7, 8};
printVec(vec);
}
编写上面两函数时,分别调用vector的 push_back() 函数,如下:
可以看出在 printVec() 函数中调用 v.push_back() 函数甚至未通过编译,所以将函数传入参数设定为const引用可以很好的避免函数内对变量增删改的操作,这样的设计可以提高代码的可读性和安全性,并传递大型数据结构时节约内存开销。
本文应该算比较详细的介绍了函数返回值和函数传入参数在使用指针亦或是引用的诸多需要注意的问题,系统地解答了什么情况下用指针或引用做函数返回值和传入参数是一个较好的选择,剩下的实际使用需要编程者根据实际情况结合使用指针和引用不同情况下优缺点来选择使用,合理高效地操作指针与引用可以帮我们规避诸多编程中遇到的怪问题。同时希望结合安全性考虑使用指针和引用,避免误操作引起的不必要的麻烦。