• 文章
  • 何时按值、引用传递参数
发布
2010 年 2 月 27 日(最后更新:2010 年 3 月 3 日)

何时按值、引用和指针传递参数

评分:4.5/5 (1196 票)
*****
我们偶尔会收到询问引用和指针之间区别的帖子,
以及在函数调用中,何时按引用、指针或值传递参数。
以及在函数调用中,何时按引用、指针或值传递参数。

引用和指针的区别
指针变量是存储其他变量内存地址的变量。
该“目标”变量可以命名,也可以不命名。
例如:

1
2
3
int i;
int* pInt = &i;  // pInt "points to" i
int* pInt2 = new int;  // pInt2 "points to" an unnamed int. 


引用变量是“引用”另一个已命名或未命名变量的变量。
已命名或未命名变量。例如

1
2
3
4
5
6
7
8
void foo( const std::string& str ) {}

std::string s1;
std::string& s1ref = s1;   // s1ref "refers" to s1

// Here, we construct an unnamed, temporary string object to call foo.
// foo's "str" parameter now "refers to" this unnamed object.
foo( std::string( "Hello World" ) );


指针有三个关键属性,使它们与引用不同。
与引用不同。

1. 您使用指针语法访问指针“指向”的值。
2. 您可以将指针重定向以使其指向不同的“目标”变量。
3. 您可以使指针不指向任何内容(即,空指针)。

例子

1
2
3
4
5
6
7
int i, j;
int* pInt = &i;  // pInt "points to" i
*pInt = 42;  // This assigns the variable pointed to by pInt to 42
             // So in other words, since pInt points to i, i now has
             // the value 42.
pInt = &j;   // This makes pInt now point to j instead of i.
pInt = NULL; // This makes pInt point to nothing. 


请注意,在指针变量之前使用星号如何访问
所指向的值。这称为解引用运算符,这个名字有点不幸,因为该语言也
支持引用,并且解引用运算符与引用无关。
与引用无关。

现在说说引用。引用有几个关键特性
它们与指针不同

1. 引用必须在实例化时进行初始化。
2. 引用必须始终“引用”一个已命名或未命名变量
(也就是说,您不能有一个引用变量不引用任何内容,这相当于一个空指针)。
(也就是说,您不能有一个引用变量不引用任何内容,这相当于一个空指针)。
3. 一旦引用被设置为引用某个特定变量,您就无法在它的生命周期内“重新绑定”它来引用不同的变量。
就无法在它的生命周期内“重新绑定”它来引用不同的变量。
变量。
4. 您使用正常的“值”语法访问被引用的值。

我们来看一些例子

1
2
3
4
5
int i = 20, j = 10;
int& iref = i;    // Instantiate iref and make it refer to i
iref = 42;        // Changes the value of i to 42
iref = j;         // Changes the value of i to 10 (the value of j)
iref = NULL;      // Changes the value of i to 0. 


所以看起来引用比指针更受限制,因为
指针的三个特性中有两个在引用中不可用。
引用。但事实上,这些限制往往使编程
更容易.

首先,在编写泛型模板代码时,您不能轻易编写
一个可以在值、引用和指针上操作的单个模板函数,因为要访问指针“指向”的值需要
指针,因为要访问指针“指向”的值需要
一个星号,而访问一个普通值不需要星号。
访问引用的值与访问一个普通值的方式相同
普通值——不需要星号。所以编写可以处理
值和引用的模板很容易。这是一个真实的例子

1
2
3
4
5
6
7
template< typename T >
void my_swap( T& t1, T& t2 ) 
{
    T tmp( t1 );
    t1 = t2;
    t2 = tmp;
}


上述函数在这些情况下效果很好

1
2
3
4
5
int i = 42, j = 10;
int& iref = i, jref = j;

my_swap( i, j );          // Sets i = 10 and j = 42
my_swap( iref, jref );    // Swaps i and j right back 


但是,如果您期望以下代码将 i 设置为 10,j 设置为 42,那么它不会执行您想要的操作。
到 10,j 到 42,那么这并不能达到您的目的

1
2
3
4
int i = 42, j = 10;
int* pi = &i, *pj = &j;

my_swap( pi, pj ); // sets pi = &j and pj = &i 


为什么?因为您需要解引用指针才能获取
指向的值,而上面的模板函数没有
其中有一个星号。

如果您想让它正常工作,您必须这样写

1
2
3
4
5
6
7
template< typename T >
void my_ptr_swap( T* t1, T* t2 )  // There are other ways to declare this
{
    T tmp( *t1 );
    *t1 = *t2;
    *t2 = tmp;
}


现在在上面的例子中,您将使用 my_ptr_swap( pi, pj ); 来交换 pi 和 pj 所指向的值。就我个人而言,我认为这个
这个解决方案很糟糕,原因有三。首先,我必须记住两个函数名,而不是一个:my_ptr_swap 和 my_swap。
名称,而不是一个:my_ptr_swap 和 my_swap。其次,my_ptr_swap 是
比 my_swap 更难理解,尽管它们有相同的行数
的代码,并且有效地做相同的事情,但涉及额外的
解引用。(我写的时候差点把函数实现错了)。第三,空指针!如果一个或两个
指针你传递给 my_ptr_swap 是 NULL?没什么好事。实际上,如果
指针您传递给 my_ptr_swap 是 NULL?没什么好事。实际上,如果
我想让 my_ptr_swap 健壮,避免崩溃,我必须这样写

1
2
3
4
5
6
7
8
9
10
template< typename T >
void my_ptr_swap( T* t1, T* t2 )  // There are other ways to declare this
{
    if( t1 != NULL && t2 != NULL )
    {
        T tmp( *t1 );
        *t1 = *t2;
        *t2 = tmp;
    }
}


但我想,这也不是一个很好的解决方案,因为现在
my_ptr_swap 的调用者不能 100% 确定函数做了什么,
除非他们重复 if() 检查

1
2
3
4
if( pi != NULL && pj != NULL )
    my_ptr_swap( pi, pj );
else
    std::cout << "Uh oh, my_ptr_swap won't do anything!" << std::endl;


但是重复检查使得 my_ptr_swap 内部的检查变得有点
毫无意义。但另一方面,函数应该始终验证其
参数。一个难题。也许应该有一个返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
template< typename T >
bool my_ptr_swap( T* t1, T* t2 )  // There are other ways to declare this
{
    if( t1 != NULL && t2 != NULL )
    {
        T tmp( *t1 );
        *t1 = *t2;
        *t2 = tmp;
        return true;
    }

    return false;  // false means function didn't swap anything
}


现在我可以写了
1
2
if( my_ptr_swap( pi, pj ) == false )
    std::cout << "Uh oh, my_ptr_swap won't do anything!" << std::endl;


这肯定更好。但这只是在您的程序中引入了一个额外的错误
您需要思考和处理的分支。如果 my_ptr_swap 失败了怎么办?我该怎么办? 在大多数微不足道的应用程序中,
错误处理,即使做了,也很容易。但在大型应用程序中
您需要执行 5 个步骤的序列,每个步骤都可能失败
意味着您必须考虑所有以下错误分支

1. 如果操作 #1 失败怎么办?
2. 如果操作 #2 失败怎么办?我是否回滚操作 #1?如果
回滚操作 #1 失败了呢?
3. 如果操作 #3 失败怎么办?我是否回滚 #1 和 #2?如果
回滚操作 #2 成功但 #1 失败了怎么办?如果回滚
回滚操作 #2 失败了呢?
4. 如果操作 #4 失败怎么办?.... 等等 ...
5. 如果操作 #5 失败怎么办?.... 等等 ...

有数量惊人的失败情况需要思考和测试。因为事情很快变得复杂,大多数程序员
只处理一个故障;双重故障通常处理得不太
优雅(程序以某种方式中止)。

在我看来,由于错误处理很容易主导设计
和实现工作量,程序员应该努力不要
在可以轻松避免的地方人为地引入错误分支。

最普遍的错误情况之一是 NULL 指针。
指针。进入引用。

(未完待续)



引用第 2 个关键特性,引自上文

2. 引用必须始终“引用”一个已命名或未命名变量
(也就是说,您不能有一个引用变量不引用任何内容,这相当于一个空指针)。
(也就是说,您不能有一个引用变量不引用任何内容,这相当于一个空指针)。

这就是引用优于指针的第二个原因
没有空指针检查!它一次性消除了所有 C++ 编程中最常见的错误情况之一!
它一次性消除了所有 C++ 编程中最常见的错误情况之一!

话虽如此,指针并非总能避免。当您动态分配内存时,
您动态分配内存时,指针必须参与。但是我们可以
通过一些明智的编程来缓解这个问题。

假设我想对一个 int 类型的 std::vector 进行冒泡排序。好吧,这是一个糟糕的例子,因为
vector<> 已经有一个排序方法,STL 也有,但请让我幽默一下。
一种方法是

1
2
3
4
5
6
7
8
9
std::vector<int>  v;  
// assume v is filled out with values

// This is REALLY suboptimal, but I'm writing this without testing it, and I 
// want to ensure I get it right:
for( size_t i = 0; i < v.size() - 1; ++i )
    for( size_t j = 0; j < v.size() - 1; ++ )
        if( v[ i ] < v[ j ] )
            my_ptr_swap( &v[ i ], &v[ j ] );


学生们被教导,当函数需要修改其参数时,应该
按指针传递。这对于 Pascal 等不支持引用的语言来说很棒,
但在 C++ 中,您有另一个选择:按引用传递。事实上,如果我用 my_swap( v[ i ], v[ j ] ); 替换 my_ptr_swap 调用,它仍然有效。而且,
my_ptr_swap 调用,my_swap( v[ i ], v[ j ] ); 它仍然有效。而且,
我消除了指针的使用。

这让我想到....

何时按值、按引用和按指针传递参数
在大学里,学生们被教导有两种情况应该按指针传递

1. 如果函数需要修改其参数;
2. 如果将变量复制到堆栈以将其传递给函数是昂贵的。

实际上,这都不是传递指针的好理由。事实上,
它们都是按引用传递的绝佳理由。在第一种情况下,您将
按非 const 引用传递。在第二种情况下,您将按 const 引用传递。
常数性超出本讨论的范围。如果您不熟悉它,
const 引用基本上是只读变量,非 const 引用是
读写变量。

但是,有没有理由按指针传递呢?有*。有时,您确实
有一个可选参数。以 C 运行时库中一个糟糕的例子为例,
time() 函数声明为

time_t time( time_t* tm );
如果您将非 NULL 指针传递给 time(),它会将当前时间写入 tm 指向的变量,
除了返回当前时间之外,还会将当前时间写入 tm 指向的变量。如果您传递 NULL
指针,那么它就不做那个。换句话说,tm 本质上是一个可选的
参数。在某些情况下,传递 NULL 指针可能会导致函数做
不同的事情。例如,pthread_create() 接受一个线程属性对象的指针。
属性对象。如果传递 NULL,它将使用新线程的默认属性。
如果传递非 NULL 指针,它将从参数中获取属性。

请注意,在我给出的两个案例中,NULL 都是程序员预期且合法的有用值。
一个传递给 my_ptr_swap 的空指针是意外的,并且被认为是调用者的编程错误。
一个传递给 my_ptr_swap 的空指针是意外的,并且被认为是调用者的编程错误。

因此,总结一下

1. 当函数不希望修改参数且值易于复制时,按值传递(int、double、char、bool 等简单类型。std::string、
值易于复制(int、double、char、bool 等简单类型。std::string、
std::vector 和所有其他 STL 容器都不是简单类型。)

2. 当值复制成本高昂,且函数不希望修改指向的值,并且 NULL 是函数处理的有效预期值时,按 const 指针传递。
不希望修改指向的值,并且 NULL 是有效、预期且函数处理的值。
函数处理。

3. 当值复制成本高昂,且函数希望修改指向的值,并且 NULL 是函数处理的有效预期值时,按非 const 指针传递。
希望修改指向的值,并且 NULL 是函数处理的有效、预期值。
函数处理。

4. 当值复制成本高昂,且函数不希望修改引用的值,并且如果使用指针,NULL 将不是有效值时,按 const 引用传递。
不希望修改引用的值,并且如果使用指针,NULL 将不是有效值。
使用指针。

5. 当值复制成本高昂,且函数希望修改引用的值,并且如果使用指针,NULL 将不是有效值时,按非 const 引用传递。
修改引用的值,并且如果使用指针,NULL 将不是有效值。
而是使用。

6. 编写模板函数时,没有明确的答案,因为有一些权衡
需要考虑,这超出了本讨论的范围,但足以说明
大多数模板函数按值或 (const) 引用接受参数,但是
因为迭代器语法与指针相似(星号用于“解引用”),任何
接受迭代器作为参数的模板函数默认也会接受指针
(并且不检查 NULL,因为 NULL 迭代器的概念有不同的语法)。