跳转至

C++ 编程指南

本课程的仿真环境需要使用 C++ 语言编写。然而,在本校的课程体系中并没有针对 C++ 的课程,相当一部分同学并不了解 C++。因此,本文将 C++ 语言做一些基础的介绍。

本文的介绍思路

对于 C++ 与 C 语言的关系有这样两种看法:“C++ 是 C 语言的超集”,以及,“C++ 和 C 是两门完全不同的语言”,这两种说法各有其合理性。本文将以“C++ 是 C 语言的超集”的思路进行介绍,着重介绍 C++ 中对 C 语言扩展的部分。

本文主要涉及到的内容有:引用传递,函数重载,类,C++ 风格的输入/输出。

在阅读完本文后,你应当能将实验框架中的 C++ 代码当作带有一些语法糖的 C 语言阅读,并能自己尝试编写简单的 C++ 程序。

1 引用传递

在 C 语言中我们会使用传值和传地址的方式向函数传递参数,它们之间的一些关键区别如下:

  • 使用传递变量的值的方式传参时,函数内无法修改原来变量的值;而使用传地址的方式传参时,可以修改原变量的值;
  • 使用传值的方式传参时,函数可以直接使用该变量的值;而使用传地址的方式传参时,函数使用或修改变量值需要使用 * 解引用。

在 C++ 中,我们引入了一种新的传参方式——传递引用。我们以下面的 swap 函数为例:

C++
void swap(int & lhs, int & rhs) {
    int temp = lhs;
    lhs = rhs;
    rhs = temp;
    return;
}

在参数声明中,我们使用了 & 来表示传递引用的参数。用如下的代码调用该 swap 函数:

C++
int a = 5, b = 3;
swap(a, b);

在函数调用完成后,会发现 a 的值变成了 3,b 的值变成了 5。也就是说,a, b 的值被成功地交换了过来。

通过上面的示例,我们了解到,传递引用的传参方式:

  • 可以修改原来的变量值;
  • 不需要使用 * 解引用,可以直接使用或修改变量的值。

可以说,这种传参的方式结合了传值和传引用两种方式的优点,十分方便好用。

传递禁止修改的引用参数

同学们有时候还会看到在一些函数中,传递了使用 const 修饰符修饰的引用参数。这可能会令你感到费解:既然不需要修改,直接使用传值的方式传参不就好了吗。

事实上,即使是不需要修改原变量值的场合,由于传引用比传值的方式更加高效,我们也常常采用传引用的方式。这时,为了防止原变量的值在函数中被意外修改,我们就可以在参数声明时加上 const 修饰符。如果在函数中真的修改了这个参数的值,静态代码分析工具和编译器就会给出相应的报错。

2 函数重载

2.1 概念

在 C 语言中,一个函数名就唯一地标识了一个函数。也就是说,假如我们想要写两个加法函数,分别用于整数和浮点数的加法,我们可能需要这么写:

C
int add_int(int lhs, int rhs) {
    return lhs + rhs;
}
float add_float(float lhs, float rhs) {
    return lhs + rhs;
}

在调用时,我们对于不同的类型的操作数,要使用与之相对应的函数。而在 C++ 中,我们可以有多个函数名相同的函数。也就是说,我们可以这样去编写代码:

C++
int add(int lhs, int rhs) {
    return lhs + rhs;
}
float add(float lhs, float rhs) {
    return lhs + rhs;
}

这样的话,不论我们想要将两个整数相加,还是将两个浮点数相加,都可以直接使用 add 函数。

这种多个函数共用一个函数名的形式,就被称为函数重载。

函数重载的注意事项

函数重载并不是任意的,想象一下,假如我们定义这样两个同名函数:

C++
int add(float lhs, float rhs) {
    return (int)lhs + (int)rhs;
}
float add(float lhs, float rhs) {
    return lhs + rhs;
}

那么我们调用 add(1.5, 1.2) 的时候,编译器如何去选择该使用哪一个重载呢?

因此,函数重载的要求是,函数的函数名可以相同,但是其函数签名必须不同。这可以简单地理解为,函数名相同时,函数的参数列表不能完全相同。值得注意的是,返回值并不在函数签名的考虑范围之内。

2.2 在 C++ 中使用 C 风格函数

在 C 语言中,一个函数编译后生成的符号就是函数名本身;而在 C++ 中,由于增加了函数重载的机制,若只用函数名作为符号则会出现符号的重复,继而链接失败。正是因为生成符号的问题,在 Verilator 的 DPI-C 机制中,我们必须要使用 C 风格函数。在 C++ 中嵌入 C 风格函数的方法是,使用 extern "C" 来修饰函数。例如:

C++
extern "C" int add(int lhs, int rhs) {
    return lhs + rhs;
}

虽然看起来和普通的函数没什么区别,但在编译时,编译器会将其作为 C 语言函数编译,生成 C 语言函数风格的符号。

一个有趣的例子

助教编写了这样的 C 程序:

C test.c
int func() {
    return 0;
}

使用 Clang 编译之后,发现其中的 func 生成了 _func 符号。然后,将上述文件的后缀名改为 .cpp,再次编译。这时,func 生成的符号变成了 __Z4funcv

将程序修改如下:

C++ test.cpp
int func() {
    return 0;
}
extern "C" int _Z4funcv() {
    return 0;
}

再进行编译,结果出现了如下的报错:

shell
$ c++ -S test.cpp -o test
test.cpp:4:16: error: definition with same mangled name '_Z4funcv' as another definition
extern "C" int _Z4funcv() {
               ^
test.cpp:1:5: note: previous definition is here
int func() {
    ^
1 error generated.

可以看到,两个函数名不同的函数竟然产生了重名的错误。mangle 意为“改编”,也就是说,C++ 通过函数签名中的参数列表等信息扩充函数名,从而使不同签名的函数最终生成的符号也不相同,继而实现函数的重载。

3 类

类可以看作是 C 语言中结构体的扩展。事实上,在 C++ 中也有结构体,它可以像 C 结构体一样使用,但它的结构和功能与 C++ 的类更加接近。

3.1 类的声明、定义与使用

C++ 中,类的关键字是 class

我们可以使用如下的语法声明一个 Foo 类:

C++
class Foo;

注意,这样声明的类是不能直接使用的,我们需要在别处定义该类。与 C 语言的结构体类似,我们可以用如下的语法定义 Foo 类:

C++
class Foo {
    int a;
    float b;
};  // Don't forget this ";"

一个类的定义只能在一处出现,否则会产生重复定义的错误。而类的声明可以出现多次,也可以没有。

与 C 语言中结构体的语法不同的是,我们想使用该类创建变量(或称为实例)的时候,不需要在前面再加上 class 关键字,例如:

C++
Foo bar;
// we don't need to write "class Foo bar"

事实上,使用 C++ 中的结构体创建变量的时候也不需要在前面加上 struct块。

3.2 类的成员与成员函数

C++ 的类与 C 语言中的结构体的一个重要的区别就在于类中可以定义成员函数,或称“方法”。以下面的代码为例:

C++
class Vector {
    float x, y;

    float multiply(Vector rhs) {
        return this->x * rhs.x + this->y * rhs.y;
        // or simpler: return x * rhs.x + y * rhs.y;
    }
};

在上面的代码中,我们定义了一个向量(Vector)类,其中有两个成员 x, y,以及一个方法 multiply

类的成员和 C 语言结构体中的成员是类似的,在 multiply 方法中我们也可以看到,rhs 访问各成员的方式和 C 语言结构体的方式是完全一样的。

然而,方法对于同学们来说可能比较陌生。方法需要通过类的一个实例来调用。例如,我们可以用下面的语句调用上述 multiply 方法:

C++
Vector lhs, rhs;
float res = lhs.multiply(rhs);

这样,就实现了将 lhs, rhs 两个向量相乘,并将结果存到 res 变量中。

我们注意到,在 multiply 方法中,有一个 this 关键字。事实上,this 是成员函数中的一个默认的指针变量,它指向调用该方法的实例。在本例中,this 指针就会指向 lhs 向量。

在很多情况下,this 指针也是可以省略的。在本例的注释中也可以看到,this->x 和直接写 x 是完全等价的。也就是说,在方法中直接访问成员,会被自动地推导为调用该方法的实例中的成员。

3.3 类的访问控制

C++ 中的类与 C 语言的结构体的另一个重要区别在于类可以指定成员或方法能否被外界所访问,我们在这里主要关注 privatepublic 两种控制等级。我们将上面的类做一些修改:

C++
class Vector {
private:
    float x, y;

public:
    float multiply(Vector rhs) {
        return this->x * rhs.x + this->y * rhs.y;
        // or simpler: return x * rhs.x + y * rhs.y;
    }
};

这样,我们就指定了对于成员 x, y 和方法 multiply 的访问控制。具体地说,我们指定成员 x, y 是私有的(private),只有在类的内部才能访问到。例如,在 multiply 方法中访问这两个成员是可以的,然而,在类的外部使用如下的代码访问这两个成员则是禁止的:

C++
Vertor vec;
float x = vec.x;
float y = vec.y;

相对应地,我们指定方法 multiply 是公共的(public),在类的外部也可以访问。也就是说,上一个例子中的 lhs.multiply(rhs) 是合法的。

Info

你可能会思考,在不指定 privatepublic 的情况下,定义的成员或方法是私有的还是公共的呢?

事实上,在 C++ 的类中,不指定访问控制时,定义的成员和方法都默认是私有的。也就是说,在一开始的例子中,lhs.multiply(rhs) 实际上是非法的。

与之相对地,C++ 中的结构体在不指定访问控制时,定义的成员和方法默认是公共的。实际上,C++ 中的类与结构体只有这一点区别。因此,我们在一开始才会说,比起 C 语言中的结构体,其结构与功能与 C++ 中的类更为接近。

4 C++ 风格的输入/输出

4.1 简单的输入/输出

在 C 语言中,我们的输入/输出主要是通过 scanfprintf(或者,更安全的 scanf_sprintf_s)完成的,我们需要使用一个格式字符串来指定输入和输出的参数、格式,紧接着,我们要传递想要输入/输出的变量。特别地,当我们传递用于存储输入的变量时,我们需要使用传指针的方式。

然而,在 C++ 中,我们可以使用更为简单的方式来实现输入/输出。以下面的程序为例:

C++ hello.cpp
#include <iostream>

int main() {
    char s[20];
    // get input string
    std::cin >> s;
    // output
    std::cout << "Hello, ";
    std::cout << s;
    std::cout << "!";
    std::cout << std::endl;
}

编译运行该程序,会得到这样的结果:

shell
$ c++ hello.cpp -o hello
$ ./hello
CECS            <- this is input
Hello, CECS!

可以看到,程序接收了我们输入的字符串,并在后面将其输出。

从上面的示例中,我们可以看到:

  • 在 C++ 中想要获取输入,只需要定义一个变量(假定变量名为 var),再使用 std::cin >> var 即可;
  • 在 C++ 中想要输出变量 var,只需使用 std::cout << var 即可;
  • 使用 std::cout << std::endl 输出换行符;
  • 使用 C++ 风格的输入/输出,我们需要包含头文件 iostream(I/O stream,输入/输出流)。

上面所说的输入/输出方式对于大多数内建类型的变量都是适用的。

你可能还是觉得这段代码太过冗长,不过,我们可以使它更加精简。C++ 是允许连续的输入/输出的,也就是说,假如我们有两个变量 var1, var2,使用 std::cin >> var1 >> var2std::cout << var1 << var2 这样的写法来输入/输出也是合法的。这样,我们的代码就可以简化为:

C++ hello.cpp
#include <iostream>

int main() {
    char s[20];
    // get input string
    std::cin >> s;
    // output
    std::cout << "Hello, " << s << "!" << std::endl;
}

你可能还会见到一些代码中直接使用 cin, cout, endl 等符号,而省去了前面的 std::。这是因为这些代码中使用了 using namespace stdstd 域中的所有符号暴露到当前域中。由于域的内容与本课程相关性不大,且这种代码风格并不好,本文不对此作深入讨论。

4.2 输出格式控制

有时,我们在输出时还希望控制输出的格式。在 C 语言中,我们是通过格式字符串来指定的。这种方式复杂晦涩,容易出错。在 C++ 中,控制输出格式是通过向 std::cout 插入流操纵算子来完成的。这种说法可能比较难以理解,我们直接列出一些常用的流操纵算子,并给出使用例。表格中带 * 号的为默认选项。

流操纵算子 描述
*std::dec 以十进制格式输出整数
std::hex 以十六进制格式输出整数
std::oct 以八进制格式输出整数
std::showbase 输出整数时,输出表示进制的前缀
*std::noshowbase 输出整数时,不输出表示进制的前缀
std::setw(int) 设定输出宽度
std::setfill(char) 指定了输出宽度的情况下,设定宽度不足时的填充字符

例如,我们想用十六进制输出一个 32 位整型变量 var,并且希望输出的结果宽度为 8(从而便于对齐),若宽度不够,在前面用 '0' 补足。我们就可以这样编写代码:

std::cout << std::hex << std::setw(8) << std::setfill('0') << var;

可以看到,向 std::cout 中插入流操纵算子的方法和一般的输出是一致的。

上述只是部分常见的流操纵算子,这些算子可以帮我们更好地去输出仿真过程中的调试信息。大家可以搜索“C++ 格式化输出”来找到更完整的说明。

5 后记

受限于本文的目标和篇幅,本文对 C++ 的介绍是非常粗浅的。比如说,C++ 相对于 C 语言最大的两个特性在于面向对象和多态;在我们介绍的内容中,类是实现面向对象的基础,而函数重载也只是多态最基本的一种形式。然而,我们的目的只是让同学们对 C++ 语法有一个基本的认识,不会去进一步地去介绍这两大特性。

如果继续深入了解,就会发现 C++ 风格的输入/输出中的 std::cinstd::cout 其实是 std::istreamstd::ostream 类的全局对象,而 std::cout << "Hello, world!" 这样看起来很奇怪的语法其实是在调用重载的 << 运算符(没错,运算符也能重载)。

对 C++ 有兴趣的同学可以阅读 C++ Primer Plus,了解 C++ 中更多有趣的特性。