modern_cpp_4-C++ Functions

文章目录

函数命名建议

Google-Style使用驼峰法命名函数,例如CamelCase,并且更加倾向于将单个函数做的更小一些。

函数返回值

关于返回值,If has a return value, must return a value. If returns void, must NOT return any value.

返回类型自动推导和返回多个值

但是在C++14中,支持返回类型自动推导(automatic return type deduction),例如

std::map<char,int> GetDictionary(){
    return std::map<char,int>{{'a',27},{'b',3}};
}

可以被表达成

auto GetDictionary(){
    return std::map<char,int>{{'a',27},{'b',3}};
}

但还是不能和Python那样返回多个值灵活:

#!/usr/bin/env python3
def foo():
    return "Super Variable",5
name,value = foo()
print(name+"has value: "+str(value))

我们尝试使用C++17中的新特性(Structured binding,元组)模仿

#include<iostream>
#include<tuple>
auto Foo(){
    return std::make_tuple("Super Variable",5);
}
int main(){
    auto [name,value] = Foo();
    std::cout<<name<<"has value: "<<value<<std::endl;
    return 0;
}

但是千万要注意,不要返回局部变量的引用,因为局部变量会随着被调用函数结束而被清理,下面的这种做法是错误的:

#include <iostream>
using namespace std;

int & MultiplyBy10(int num){
    int retval = 0;
    cout<< "retval is "<<retval <<endl;
    return retval;
}// retval is destroyed, it's not accesisble anymore

int main(){
    int out = MultiplyBy10(10);
    cout<< "out is "<<out<<endl; // not 0
    return 0;
}

RVO(Return Value Optimization)

C++针对Return Value还会进行优化,可以参考Copy elision - Wikipedia

Copy Elision指一个编译器优化技巧,即消除不必要的对象的复制,其中一个重要体现就是RVO(Return Value Optimization)。

C++中有一个假设原则(as-if rule),即C++标准允许编译器执行任何程度的优化,只要生成的可执行文件表现出相同的可观察行为。而RVO指的是C++中的一个特殊语句,比假设原则更进一步,即使拷贝复制函数有作用,实际操作中也可以忽略由return语句产生的复制操作。

比如下面的程序:

#include <iostream>

struct C {
  C() = default;
  C(const C&) { std::cout << "A copy was made.\n"; }
};

C f() {
  return C();
}

int main() {
  std::cout << "Hello World!\n";
  C obj = f();
}

这里按理说,应该会执行两次拷贝复制函数,即输出两次A copy was made.,第一次是f()内部将一个未命名的临时C复制拷贝给Return Value,第二次是main内部的fobj,但是结果有点出乎意料:

modern_cpp_4-C++ Functions

实际中,根据编译器的优化不同,下面三种情况都有可能会发生

Hello World!
A copy was made.
A copy was made.
Hello World!
A copy was made.
Hello World!

我们的编译器在这里什么也没做,直接在main函数中创建新的C,确实是最简洁的实现,这种方法是被Walter Bright第一次在他的Zortech C++ compiler中实现的,而目前的绝大多数的C++编译器都支持了RVO。

局部变量和静态变量

除非声明全局静态变量,否则每次触发局部变量的定义,都会创建一份局部变量:

void f(){
    float var1 = 5.5F;
    float var2 = 1.5F;
}
f(); // First call,var1,var2 are created
f(); // Second call,NEW var 1,var2 are created

NACHO-STYLE: 如果可能的话,请避免使用静态变量,下面的例子会介绍一种微妙的使程序崩溃的方法,即”initialization order ’fiasco‘ “(静态变量的初始化顺序),这种错误很难觉察到,而且发生在main函数开始之前。可以参考这篇文章:https://isocpp.org/wiki/faq/ctors#static-init-order

在C++标准 ‘ISOIEC14882-1998 3.6.2 Initialization of nonlocal objects’ 里明确规定,在同一个编译单元内,静态变量初始化的顺序就是定义的顺序,而跨编译单元的静态变量的初始化顺序未定义,具体的初始化顺序取决于编译器的实现。所以如果跨编译单元的定义使用静态变量,有可能所调用的静态变量还没有初始化,这是十分危险的。

当然静态变量的初始化分为静态初始化和动态初始化,前者可以认为是同步完成的,后者就会因为跨编译单元而导致初始化顺序不确定。

防范措施:尽量避免跨编译单元的初始化依赖,常用construct on first use idiom,即将静态变量定义在函数内,直到函数被调用才会被初始化,这就消除了跨编译单元的静态变量的初始化问题。

默认参数

例子:

#include<iostream>

using namespace std;

string SayHello(const string& to_whom = "world"){
        return "Hello"+ to_whom +"!";
}

int main(){
        // Will call SayHello using default arguments
        cout<<SayHello()<<endl;
        // This will override the default argument
        cout<<SayHello("students")<<endl;
        return 0;
}

但是默认参数只能够在定义中设置,不能在声明中设置,一方面简化了程序,另一方面会造成使用者在调用时,忽略已经设置的默认参数,当在同一个作用域内,再次设置默认参数时会造成意料之外的行为

// 下面的代码在编译时会出错
#include <iostream>
using namespace std;
void func(int a, int b = 10, int c = 36);
int main(){
    func(99);
    return 0;
}
void func(int a, int b = 10, int c = 36){
    cout<<a<<", "<<b<<", "<<c<<endl;
}

modern_cpp_4-C++ Functions

如果把当前函数的定义和声明分开,仍然设置两次默认参数,就不会出现这个问题了(但是我还是出现了问题,这好像应该就是一个作用域吧,日后可以再琢磨琢磨: C++函数的默认参数详解)。

总结:

在给定的作用域中,一个形参只能被赋予一次默认实参,换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。通常应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。

GOOGLE-STYLE规定,只有当可读性更好的时候才能使用默认参数

NACHO-STYLE规定,不应该使用默认参数

更多参考:到底在声明中还是定义中指定默认参数 (biancheng.net)

传递较大的参数应使用Const Reference

当传递的参数较大时,函数直接拷贝可能会比较耗时,此时应该传递引用。

而且如果可以的话,应该尽量使用常量引用(尽管再C++11之前,非常量引用是很常见的),GOOGLE-STYLE中提出了避免使用non-const refs.

实验:Cost of passing by value

这里针对值传递和引用传递进行了性能的对比,后者要比前者快五倍。

inline

内联函数是用来提醒编译器,针对该部分 should attempt to generate code for a call rather than a function call,当然编译器会进行决断,如果这些代码块足够小,编译器会进行优化。

例如下面的阶乘:

inline int fac(int n){
    if(n<2)
        return 2;
    return n*fac(n-1);
}

Overloading

Naive overloading

在标准库中的数学计算库cmath中,简单的余弦和正切都会按照输入参数的类型而在命名上产生差异:

#include<cmath>
double cos(double x);
double cosf(float x);
long double cosl(long double x);

但是在实际中我们只用使用cos就可以了,这里就是实现了重载:

#include<cmath>
double cos(double x);
double cos(float x);
long double cos(long double x);

编译器会根据输入的参数类型推断应该采用哪个函数(注意,不是根据返回值的类型推断!)

GOOGLE-STYLE:避免不明显的重载。

Good Practices & Bad Practices

  1. 将复杂的计算拆解成模块更小,意义更加紧凑的子模块
  2. 函数的长度应该越小越好
  3. 避免无意义的注释
  4. 一个函数应该只承担一项任务
  5. 如果你不能去一个简单的名字,那就用它的功能命名它

实践

// Good Practice
#include<vector>
#include<iostream>

using namespace std;

vector<int> CreateVectorOfZeros(int size);

int main(){
        vector<int> zeros = CreateVectorOfZeros(10);
        return 0;
}

Namespaces

命名空间能够防止命名上的冲突和从逻辑上可以更加容易管理程序,下面的例子简单展示了如何使用Namespace:

#include<iostream>

namespace fun{
        int GetMeaningOfLife(void){
                return 42;
        }
}

namespace boring{
        int GetMeaningOfLife(void){
                return 0;
        }
}

int main(){
        std::cout<<boring::GetMeaningOfLife()<<std::endl;
        std::cout<<fun::GetMeaningOfLife()<<std::endl;
}

有时候,我们可以利用{}来隔绝定义域,在小范围内使用某种命名空间:

#include<cmath>
#include"my_pow.hpp"

int main(){
    int std_result = std::pow(2,3);
    int my_result = my_pow::pow(2,3);
    {
        using std::pow;
        result = pow(2,3); // same as std::pow
    }
    {
        using my_pow::pow;
        result = pow(2,3); //same as my_pow::pow
    }
}

记住:永远不要在*.hpp中使用using namespace name

GOOGLE_STYLE: 如果你发现自己依赖于某些内容,而且这些内容不应该在其他文件中被看到,你就要在当前文件的开头把它们放进namespace

更多参考 https://google.github.io/styleguide/cppguide.html#Namespaces

上一篇:Python试题(转载)


下一篇:C++中多线程、多页面、多文件共享变量及具体读/写实现