Essential C++ Lippman 第2章 面向过程的编程风格 摘录

通常我们会抽取通用的操作,将它们实现为独立函数。将函数独立出来有三个主要好处:第一,以一连串函数调用操作,取代重复编写相同的程序代码,可使程序更容易读懂。第二,我们可以在不同的程序中使用这些函数。第三,我们可以更容易地将工作分配给协作开发团队。

2.1 如何编写函数

每一个函数必须定义一下四个部分:

        1. 返回类型;

        2. 函数名:表明函数操作的实际内涵;

        3.参数列表(parameter list):函数参数扮演着占位符(placeholder)的角色。它让用户在每次调用函数时,将要传入的值放在其中,以便函数使用。参数的指定包括类型及名称。

        4. 函数体:操作本身的工作逻辑的实现内容。通常它会使用函数的参数值。函数体紧接在参数列表之后,由大括号括起来。

函数必须先被声明,然后才能被调用。函数的声明让编译器检查后续出现的使用方式是否正确--是否有足够的参数、参数类型是否正确,等等。函数声明不必提供函数体,但必须指明返回类型、函数名,以及参数列表。此即函数原型(prototype)。

/*
* 
* Fibonacci数列的第一元素和第二元素
* 都是1,接下来每个元素都是前两个元素之和。
* 
* elem:持有即将返回的值
* n_2,n_1:持有前两个元素的值
* pos:数列中的元素位置(由调用者指定)
* 
*/

// function prototype
bool fibon_elem(int);

// function definition
bool fibon_elem(int pos, int& elem)
{
    // 检查位置是否合理,限制上限位置,是由于int数表示范围有限
    if (pos < 0 || pos > 1024)
    {
        elem = 0;
        return false;
    }

    // 位置为1或2时,elem值为1
    elem = 1;
    int n_2 = 1, n_1 = 1;

    for (int ix = 2; ix <= pos; ++ix)
    {
        elem = n_2 + n_1;
        n_2 = n_1;
        n_1 = elem;
    }

    return true;
}

也有人建议函数原型也指定参数名称,以作为一种文档使用。

如果函数的返回类型不为void,那么它必须在每个可能的退出点上将值返回。函数中的每条return语句都被用作表示该处就是函数的退出点。如果函数体的最后一条语句不是return,那么最后一条语句之后便是该函数的隐式退出点。

return语句的第二种不返回任何数值。只有在函数的返回值为void才会被使用。

void funct()
{
...
    return;
}

2.2 调用函数

程序第一次执行时结果不正确,本属稀松平常。

利用swap()函数交换传入的两个值。

第一个版本,pass by value

void swap(int val1, int val2)
{
    int temp = val1;
    val1 = val2;
    val2 = temp;
}

当函数内的参数的确交换了值,但其结果却没有反应到传入的参数上。

当我们调用一个函数时,会在内存中建立起一块特殊区域,称为“”程序堆栈(pragram stack)”。这块特殊区域提供了每个函数参数的存储空间。它也提供了函数所定义的每个对象的内存空间--local object。一旦函数完成后,这块内存就会被释放掉,或者说从程序堆栈中被pop出来。

当我们使用pass by value(传值)时,默认情况下其值会被复制一份,成为参数的局部性定义(local definition)。实际上,我们传入swap()的对象和我们在swap()内操作的对象,其实是没有任何关系的两组对象。

必须使用某种方法,另swap()的参数和传入的实际对象产生关联。此即所谓的pass by reference(传址)。要达成这个目的,最简单的做法便是将参数声明为一个reference。

void swap(int& val1, int& val2)
{
    int temp = val1;
    val1 = val2;
    val2 = temp;
}

需要检查的便是函数的传递应该采用传址方式而非传值方式。

reference扮演着外界与对象之间一个间接手柄的角色。只要在类型名称和reference名称之间插入&符号,便是声明了一个reference。

int ival = 1024;    // 对象,类型为int
int* pi = &ival;    // 指针,指向一个int对象
int& rval = ival;   // 引用,代表一个int对象

pi = &ival,其实是将ival(rval所代表的的对象)的地址赋值给pi。我们并未令pi指向rval。重点是,面对reference的所有操作都和面对“reference所代表的的对象”所进行的操作一般无二。

当我们以by reference方式将对象作为函数参数传入时,对象本身并不会复制出另一份--复制的是对象的地址。函数中对该对象进行的任何操作,都相对于是对传入的对象进行间接操作。

将参数声明为reference的理由之一是,希望得以直接对传入的对象进行修改。第二个理由是,降低复制大型对象的额外负担。

如果我们愿意,也可以以pointer形式传递。这和reference传递的效果相同:传递的是对象地址,而不是整个对象的副本。唯一差别在于reference和pointer用法不同。pointer和reference参数之间更重要的差异是,pointer可能(也可能不)指向某个实际对象。当我们解引用pointer时,一定要先确定其值并非为0,。至于reference,则必定会代表某个对象,所以不需要做此检查。

除非你希望在函数内更改参数值,否则我建议在传递内置类型时,不要使用传址方式。传址机制主要用于传递class object。

除了static变量之外,函数内定义的对象,只存在于函数执行期间。如果将这些所谓局部对象(local object)的地址返回,会导致运行时错误。还记得么,函数是暂时位于程序堆栈上。局部对象就放在这块内存中。当函数执行完毕,这块区域的内容就会被弃置。

一般而言,对根本不存在的对象进行寻址操作,是很不好的习惯。

为对象分配的内存,其存活时间称为储存期(storage duration)或范围(extent)。例如temp,在swap()结束后便被释放。

对象在程序内的存活区域称为该对象的scope(作用域)。temp在swap外便不可见。

对象如果在函数外声明,具有所谓的file scope。对象如果拥有file scope,从其声明点至文件末尾都是可见的。file scope内的对象也具备所谓的static extent,意即该对象的内存在main()开始执行之前便已经分配好了,可以一直存在至程序结束。

内置类型的对象,如果定义在file scope之内,必定被初始化为0。但如果它们被定义于local scope之内,那么除非程序员指定其初值,否则不会被初始化。

不论local scope和file scope,对我们而言,都是由系统自动管理。第三种存储期形式成为dynamic extent(动态范围)。其内存系由程序的空闲空间(file store)分配而来,有时也被称为heap memory(堆内存)。这种内存必须由程序员自行管理,其分配系通过new表达式来完成,而其释放则通过delete表达式来完成。

new Type;
new Type(initial_value);    // 初始化

int* pi = new int;
int* pia = new int[24];    // 创建数组

delete pi;
delete[] pia;

pia会被初始化为数组第一个元素的地址。数组中的每个元素都未经初始化。C++没有提供任何语法让我们得以从heap分配数组的同时为其元素设定初值。

删除new分配的普通对象,只需使用delete。删除new分配的数组,则需要delete []。

此时无需检查pi是否为0。

如果某种原因,程序员不想使用delete表达式,有heap分配而来的对象就永远不会被释放。这称为memory leak(内存泄露)。

2.3 提供默认参数值

一般程序的编写法则是,以参数传递作为函数间的沟通方式,比直接将对象定义为file scope更适当。理由之一是,函数如果过度依赖定义与file scope内的对象,就比较难在其他环境中重用,也比较难修改----我们不仅需要了解该函数的工作逻辑,也必须了解定义与file scope中的那些对象的工作逻辑。

void bubble_sort(vector<int>& vec, ofstream& ofil)
{
    for (int ix = 0; ix < vec.size(); ++ix)
        for (int jx = ix + 1; ix < vec.size(); ++jx)
            if (vec[ix] > vec[jx])
            {
                ofil << "about to call swap! ix: " << ix
                    << " jx: " << jx << "\tSwapping: "
                    << vec[ix] << " with " << vec[jx] << endl;
                swap(vec[ix], vec[jx], ofil);
            }
}

每次调用bubble_sort()都必须传入一个ofstream对象,而且用户无法关闭我们所产生的信息。C++允许我们为全部或部分参数设定默认值。本例中,我们将ofstream指针参数默认值设为0,且我们必须将ofstream对象声明为指针而不是引用。因为只有指针才能设定默认值为0,表示并未指定任何ofstream对象,而reference一定得代表某个对象;

void bubble_sort(vector<int>& vec, ofstream* ofil = 0)
{
    for (int ix = 0; ix < vec.size(); ++ix)
        for (int jx = ix + 1; ix < vec.size(); ++jx)
            if (vec[ix] > vec[jx])
            {
                ofil << "about to call swap! ix: " << ix
                    << " jx: " << jx << "\tSwapping: "
                    << vec[ix] << " with " << vec[jx] << endl;
                swap(vec[ix], vec[jx], ofil);
            }
}

关于默认参数值的提供,有两个不很直观的规则。第一个规则是,默认值的解析(resolve)操作有最右边开始进行。如果我们为某个参数提供了默认值,那么这一参数右侧的所有参数都必须也具有默认参数值才行。

第二个规则是,默认值只能指定一次,可以在函数声明处,也可以在函数定义处,但不能够在声明与定义两个地方同时都指定。

为了更高的可见性,我们决定将默认值放在函数声明处而非定义处。

2.4 使用局部静态对象

局部对象会在每次调用函数时建立,并在函数结束的同时被弃置。如果将vector对象定义于file scope之中,又过于冒险。是的,为了节省函数之间的通信问题而将对象定义于file scope内,永远是一种冒险。通常,file scope对象会打乱不同函数间的独立性,使它们难以理解。如果想使局部对象在函数结束后不被销毁,一个解法是使用局部静态对象(local static object)。

const vector<int*> fibon_seq(int size)
{
    static vector<int> elems;
    return &elems;
}

局部静态对象所处的内存空间,即使在不同的函数调用过程中,依然持续存在。这也就是现在我们可以安全地将elems的地址返回的原因。

2.5 声明inline函数

此前的fibon_elem()的函数体过长,我们将fibon_elem()里面的某些操作抽象出另外三个函数。这样fibon_elem()的实现变得更单纯、更易理解。但是,此前调用fibon_elem()函数,只需调用它本身,而现在,调用fibon_elem()还需要调用其他三个函数。这样引入了的函数调用负担。C++的解决方法是,将这些函数声明为inline。

将函数声明为inline,表示要求编译器在每个函数调用点上,将这些函数的内容展开。面对一个inline函数,编译器可将该函数的调用操作改为一份函数代码副本代替。

只要在函数前面加上关键字inline,便可声明该函数为inline。

inline
bool fibon_elem(int pos, int& elem)
{
    ...
} 

将函数指定为inline,只是对编译器提出一种要求。编译器是否执行这项请求,需视编译器而定。

一般而言,最适合声明为inline的函数特征为:体积小,常被调用,所从事的计算并不复杂。

inline函数的定义,常常被放在头文件中。由于编译器必须在它被调用时加以展开,所以这个时候其定义必须是有效的。

2.6 重载函数

bool is_size_ok(int size)
{
    const int max_size = 1024;
    const string msg("Requested size is not supported");
    
    if (size <= 0 || size > max_size)
    {
        display_message(msg, size);
        return false;
    }
    return true;
}

我们是否可以传入不同类型甚至不同数量的参数给display_message()呢? 必须通过所谓的函数重载机制(functionoverloading)。

参数列表不相同(可能是参数类型不同,可能是参数个数不同)的两个或多个函数,可以拥有相同的函数名称。

既然名称相同,编译器是如何知道该调用哪个函数呢? 它会根据调用者提供的实际参数拿来和每个重载函数的参数对比,找出其中最合适的。这也就是每个重载函数的参数列表和其他重载函数的不同的原因。

编译器无法根据函数返回类型来区分两个具有相同名称的函数。因为返回类型无法保证提供我们一个足以区分不同重载函数的语境。

将一组实现代码不同但工作内容相似的函数加以重载,可以让函数用户更容易使用这些函数。如果没有重载机制,我们就得为每个函数提供不同的名称。

2.7 定义并使用模板函数

需要一种机制,让我们得以将单一函数的内容与希望显示的各种类型绑定起来(bind)。所谓function template(函数模板)便提供了这样的机制。

function template将参数列表中指定的全部或部分参数的类型信息抽离出来。但是,模板信息是不完整的,因为我们遗漏了抽离出来的类型信息。这份类型信息系由用户提供--当他决定采用function template的某个实例时提供。

function template以关键字template开场,其后紧接着以成对尖括号<>包围起来的一个或多个标识符。这些标识符用以表示我们希望推迟决定的数据类型。用户每次利用这一模板template产生函数,都必须提供确实的类型信息。这些标识符事实上扮演着占位符的角色,用来放置参数列表及函数体中的某些实际数据类型。例如:

template <typename elemType>
void display_message(const string& msg, const vector<elemType>& vec)
{
    cout << msg;
    for (int ix = 0; ix < vec.size(); ++ix)
    {
        elemType t = vec[ix];
        cout << t << ' ';
    }
}

关键字typename表示,elemType在display_message函数中是一个暂时放置类型的占位符。elemType只是个任意名称,我们也可以选择用Foobar或T之类的名称。

function template的参数列表通常由两种类型构成,一类是明确的类型,另一类是暂缓决定的类型。

如何使用function template? 使用方式跟普通函数极为相似。

vector<int> ivec;
string msg;
//...
display_message(msg, ivec);

编译器会将elemType绑定(bind)为int类型,然后产生一份display_message()函数实例,于是第二个参数的类型变成vector<int>。函数体内的局部对象的类型同样也变成了int。

function template扮演的角色如同处方药一般,我们可以通过它产生无数函数,其elemType可以被绑定为内置类型或用户自定义类型。

一般而言,如果函数具备多种实现方式,我们可以将它重载(overload),其每份实例提供的是相同的通用服务。如果我们希望让程序代码的主体不变,仅仅改变其中用到的数据类型,可以通过function template达到目的。

2.8 函数指针带来更大的弹性

我们必须提供一个类似于fibon_seq(),可以通过vector返回另五种数列的函数。做法之一是定义另外五种函数。

bool fibon_elem(int pos, int& elem)
{
    const vector<int>* pseq = fibon_seq(pos); // A
    ...
}

那么fibon_elem()呢? 难道也要针对六种函数提供六种定义? 在观察fibon_elem()的定义后,唯一和数列相关的部分是A,如果我们可以消除这个关联,便可以不必提供多个类似的fibon_elem()函数。可以通过函数指针(pointer to function)达到这一目的。

所谓函数指针,其形式相当复杂。他必须指明其所指函数的返回类型及参数列表。此外,函数指针的定义必须将*放在某个特定的位置上,表示这份定义所表现是一个指针。必须要用括号改变运算优先级。本例的函数指针如下所示:

const vector<int>* (*seq_ptr)(int);

现在,seq_ptr可以指向“具有所列返回类型及参数列表”的任何一个函数。这意味着它可以分别指向前述六个数列函数。则可以将fibon_elem()重新写过,使它更通用。

bool seq_elem(int pos, int& elem, const vector<int>* (*seq_ptr)(int))
{
    const vector<int>* pseq = seq_ptr(pos); // A
    ...
}

由函数指针指向的函数,其调用方式与一般函数相同。

const vector<int>* pseq = seq_ptr(pos); 

我们也可以给函数指针初值。如果初值为0,表示并未指向任何函数。

const vector<int>* (*seq_ptr)(int) = 0;

也可以拿某个函数的地址作为函数指针的初值。只需要提供函数名称即可。

seq_ptr = fibon_seq;

如果希望创建函数指针数组内:

const vector<int>* (*seq_array[])(int) = {fibon_seq, lucas_seq, pell_seq, triang_seq, square_seq, pent_seq};

如果我们想要明确指定函数的方式来产生Pell数列,如果必须要记住Pell数列是由数组第三个元素指出。直接记住的话,那便显得有点笨拙。更直接的方式是通过一组辅助记忆的常量来进行索引操作。例如:

enum ns_type {ns_fibon, ns_lucas, ns_pell, ns_triang, ns_square, ns_pent};

关键字enum之后是一个可有可无的标识符,如本例的ns_type。借此便定义出所谓枚举类型(enumerated type)。大括号里头是以逗号分隔的列表,其中的每个项称为枚举项(enumerator)。默认第一项的值为0,接下来每一个枚举项比前项加1。

为了明确指定的方式取用函数指针,我们使用对应的枚举项作为数组索引值。

seq_ptr = seq_array[ns_pell];

2.9 设定头文件

调用seq_elem()之前,必须先声明它,以便程序知道它的存在。为了不用分别在五个文件中声明seq_elem(),我们把函数声明放在头文件中,并在每个程序代码文件内包含(include)这些函数声明。

使用这种编写习惯,只需为函数维护一份即可。如果其参数列表或返回类型需要改变,也只需更改这份声明即可--函数用户会包含更新后的函数声明。

“只定义一份”的规则有个例外:inline函数的定义。为了能够扩张inline函数的内容,在每个调用点上,编译器都取得其定义。这意味着我们必须将inline函数的定义放在头文件中,而不是把它放在各个不同的程序代码文件中。

在file scope内定义的对象,如果可能被多个文件访问,就应该被声明在头文件中。因为如果我们没有在程序中声明某个对象,便无法加以访问。

如果要想访问其他文件中的定义,可以用extern关键字进行引用声明。

const object就和inline函数一样,是“一次定义”规则下的例外。const object的定义只要一出文件之外便不可见。

如果头文件和包含此文件的程序代码位于同一磁盘目录下,我们便使用双引号将文件名包含起来。

如果此文件被认定Wie标准或项目专属的头文件,我们便以尖括号将文件名括起来。编译器搜索此文件时,会先在有些默认的磁盘目录中寻找。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值