预备一 C++的最小子集

XMUT.SE.DS2022年9月12日大约 18 分钟

预备一 C++的最小子集

为什么需要C++

简单说,为了避免不必要的重复轮子。

数据结构除了告诉你应该如何编写数据结构之外,也必须回答:什么情况下使用何种数据结构。 因此用数据结构来解决问题是必不可少的。 我们当然可以选择自己编写的数据结构来解决问题,但你写的一定不如别人的内置库。 因为库由最优秀的工程师编写,同时在实践中经历过各种环境的毒打,易用性和鲁棒性都得到了充分的证明。

其次,C++简化了一些C语言中的语法,通常情况下,C++编写的代码更紧凑且更安全。

最后,学习C++对于大家学习Java有很好的借鉴意义,可以补足你们对面向对象语言底层逻辑的短板。

C++语言的学习不是必须的,我们课程教材采用的是类C伪代码,不会C++不影响你理解书本内容。 此外我们上课授课可能会采用C++,但从语法角度,两者区别并不大,也不会造成很大的理解困难。 最后OJ平台的所有代码都提供两种语言的版本(除去专门训练C++的题目),也不会影响你刷题。

Hello World!

我们先看一个典型的C++代码:

#include <iostream>

int main(){
    std::cout << "Hello World!" << std::endl;
    return 0;
}

代码总体上还是C语言风格式的,但你可以看到与C语言的几点不同:

C++的头文件

第一行

#include <iostream>

iostream是C++的标准输入输出流头文件,你可以理解为C++的stdio.h,这里可以发现C++和C的第一个区别,iostream不带.h。事实上所有C++的系统头文件,都不带.h后缀。 这是一个历史遗留问题。在C++98还未制定时,各家编译器对C++的实现五花八门,相互之间不兼容。C++98如果沿用某一家的标准,或者都不用,就会导致新旧代码的不兼容问题。因此,C++98采用了这种无.h头文件的形式,事实上提供了一套新的头文件体系,编译器可以保留原本的旧版支持的同时,引导用户慢慢转向新标准的头文件,这是一种无奈但很聪明的做法[1]

C++标准输入输出流

std::cout << "Hello World" << std::endl;

你会发现这里并不用printf来输出字符串,或者专业的说法,叫向标准输入输出流输出内容

标准输入输出流

如果你不知道什么是标准输入输出,请自行STFW。

此外这里有一个很全面的介绍:【Linux基础】linux下的stdin,stdout和stderr理解open in new window 其中包括了基本介绍和很重要的重定向概念,事实上重定向对你们刷题有很大的帮助。

题目虽然写的是Linux基础,实际上对所有平台都适用。

在linux下试试看:

echo "Hello World" > /dev/null

会发生什么?去掉后面的>/dev/null又会发生什么,所以,/dev/null到底是个啥?

这里,cout是C++的标准输出流(类似于C里面的stdout),这里<<是C++里的一种机制,称为“运算符重载”,意思是C++可以某些对象的运算符进行重定义,以方便使用。cout对象,也就是标准输出流,重载了<<,意思是将变量(或常量)输出到标准输出流。

注意C++的输出非常智能,你不需要告诉他,是%d,还是%s,还是%f,他会自动识别:

std::cout << 123 << 123.0f << "Hello World!" << std::endl;

这依赖于一种叫函数重载(以及,运算符重载)的机制。

这点是coutprintf方便的地方。但相应的,cout的输出格式化变得很麻烦(你不需要了解)。所以如果输出格式比较多变,我们一般不建议用cout,使用printf往往会更方便一些。

与之相对的还有输入cin(类似C里面的stdin

int a;
float b;
std::string s;
std::cin >> a >> b >> s;

用户输入:

1 2 Hello

会分别为a赋值数字1,b赋值浮点数2.0,字符串s赋值"Hello"。相对来说,cinscanf函数要好用很多,唯一的缺点是速度比较慢,和cout不同,大多数情况下cin都比scanf要方便。

最后std::endl是换行的意思,你可以认为他等同于\n

这样一看,整个代码就很好理解了。

namespace

你可能注意到我有时候用std::cout,有时候用cout。后者其实是简写,前者才是完整的命名,std这部分,是C++的namespace,::表示从属关系。

namespace看字面意思,就是所谓的命名空间。你可以把命名空间看成java的包名,他可以很大程度上避免命名冲突的问题。

比如A模块有一个全局变量global,B模块不知道这点,他也写了一个全局变量global,那么链接的时候就会发生重名问题,链接器不知道你说的global是A模块的那个,还是B模块的那个。

比如VS2019就会触发一个: fatal error LNK1169: 找到一个或多个多重定义的符号 的错误。

如果A模块申明namespace A,把global放入namepspace A里,B也申明namespace B,把global放入namespace B里,那么编译器可以通过前缀A::global或者前缀B::global来正确识别你指的是哪个global,避免这个问题。

上面代码的std就是一个namespace,std是C++标准库的命名空间,std means standard。所有C++库文件的变量、函数、甚至宏定义,都位于这个命名空间之下。

所以你必须用std::cout,才能引用到cout这个变量。

但每次都这样写很烦,C++允许你用using namespace来引入某个命名空间里的所有东西(正确术语叫:符号symbol)

#include <iostream>
using namespace std;

int main(){
    cout << "Hello World!" << endl;
    return 0;
}

当你using namespace std后,你就不需要特别指明std::cout了。这就好比二舅家的熊孩子叫小明,你平时总叫他“二舅家的小明”,但如果有天二舅一家都来你家玩,你直接说“小明”,不用再特地强调“二舅家的”,大家都知道你指的是他。

但要特别注意,如果两个命名空间存在重名符号,使用using namespace引入两个命名空间,就会出现冲突了。就好像你三姑家的熊孩子也叫小明,你二舅和三姑都到你家做客,你再叫小明,就不知道叫的是谁了。

因为这个问题,所以using namespace xxx一般被认为不是很好的代码风格,但我们不写复杂系统,一般没必要计较这些。

C头文件兼容性

C++最大程度兼容C语言的既有代码,包括C的库文件。但在新的C++标准里,使用这样的方式:

#include <cstdio>
int main(){
    std::printf("Hello World!\n");
}

对于C的xxxxx.h库文件,C++推荐以cxxxxx的方式引入。比如:

C库文件C++库文件
stdio.hcstdio
stdlib.hcstdlib
string.hcstring
assert.hcassert

同时,注意C++标准把所有C库函数都放入std空间,也就是必须像我上面这样用std::printf。但你可以发现,即便你不这么写,在VS2019里也能正确处理,而在gcc里则不能。这是vs2019单方面做出的修改,如果你要编写兼容性要求很高的代码(比如我们的oj,内部编译器是gcc),不要这么用。图省事可以直接using namespace std

同时要注意,一般stdio.h这样的头文件,也是存在的,你一样可以引入,但从C++的角度,这同样不是兼容性很好的做法。

引用,更安全的指针

看下面的代码:

void swap( int a, int b ){
    int c = a;
    a = b;
    b = c;
}
int x=1;
int y=2;
swap(x, y);

这个swap函数是无法奏效的,如果你不理解,你可能需要再去重修一下C语言。 正确的做法是用指针:

void swap( int* a, int* b){
    int c = *a;
    *a = *b;
    *b = c;
}

int x=1;
int y=2;
swap(&x, &y);

但这种做法不管写(函数的人),还是用(函数的人)感觉都略显繁琐。 C++引入了新的类型引用,用比较简洁的方式解决了这个问题:

void swap( int &a, int &b){
    int c = a;
    a = b;
    b = c;
}
int x=1;
int y=2;
swap(x, y);

这里的swap可以奏效,&类似于指针的*,引用可以像正常变量一样使用,但他只是一个绑定的副本,你可以理解成快捷方式,你对引用做的任何修改,都会导致引用指向的变量被修改,这点和指针是一样的。 或者你可以换种理解方式,JavaScript里的复杂对象(List和Object),向函数内部传值时,就采用引用传递的方法。

引用也是用指针实现的,但和指针不同的是,引用必然会绑定到某个变量上,同时你不能修改这个绑定:

int &a; // 非法,无绑定的引用
int &a = b;
a = c; // 是把a赋值成c的值,而不是修改a的绑定对象。

因此引用使用起来比指针要安全很多,尤其对初学者而言,多数情况下你的指针都可以替换成引用。

字符串类string

string是STL的一部分。STL是Standard Template Libaray的缩写,标准模板库。STL是C++标准的一部分,和C语言不同的是,STL基本都用模板来实现。 STL提供了一组非常有用的数据结构(称为容器),和与之相对的算法。并用一种很巧妙的方法将两者集合起来。STL大大简化了C++编码的复杂度。我们后面会看到这些容器。现在先看字符串。

C语言的一大痛点是字符串处理,如果要选一个非用脚本语言不可的理由,那必须是字符串处理能力。 简简单单一个拼接,所有脚本语言都用+就可以实现,但C语言却缺乏这个能力。究其原因,与你想的不同,字符串处理是个逻辑相当复杂的技术活,而一个复杂庞大的实现和C语言的设计哲学是相抵触的。

C++的string可以很大程度解决这个问题,代价是,你不知道代价是什么(这是C语言极力避免的)。

#include <string> //注意string,不带h
using namespace std;

int main(){
    string input = "input your name:";
    string s; // 申明一个字符串。
    cout << input; // 输出input
    cin >> s; // 从stdin输入一个字符串
    s = "Hello " + s; // 直接用+拼接
    cout << s << endl;
}

string允许你用+、+=来直接拼接字符串。可以从cin直接输入,也可以直接输出到cout。

甚至可以直接对比:

string s1="1", s2="2";
if(s1==s2){
    cout << "true" << endl;
}
else{
    cout << "false" << endl;
}

这都有赖于C++的运算符重载(这是我们第二次遇到他了)。 总之,string可以让你用一种比较自然的方式来处理字符串,两者也可以相互转换:

string s = "hello world!";
printf("%s\n", s.c_str());

可以直接用const char*string赋值。当你需要把字符串转成const char*,调用stringc_str()函数就可以了。

思考一下

你觉得string内部是如何实现这些操作的?举例来说,执行+=的时候,做了什么?

C++的其他便利

简化的结构体定义

对C语言,结构体定义通常都比较繁琐:

struct A {
    //...
};

struct A a;

申明变量时要多写一个struct来进行申明。

因此一般我们会用typedef来进行处理:

typedef struct {
    // ...
} A;

A a;

这种变通方式其实也有一些问题,比如当你定义链表时:

typedef struct __A{
    // ..
    struct __A* next;
} A;

你需要额外给一个定义,否则在内部无法引用A(因此此时编译器还不知道A是什么)。 对C++则简化多了:

struct A{
    //...
};

A a; // A可以直接当类型使用。

变量随处定义

对于早期的C语言编译器,变量必须在头部申明:

int f(){
    int a,b,c; // 头部申明
    a = 0;
    ++a;
    b = a;
    ++b;
    c= b;
    ++c;
}

C++允许变量需要时才申明和定义

int f(){
    int a = 0;
    ++a;
    int b = a; // 中途申明 b
    ++b;
    int c= b; // 中途申明 c
    ++c;
}

作用域局部化

C99以前的C语言实现里变量作用域都是整个函数,要求变量必须在头部申明。

int f(){
    int i,j; //头部申明
    for(i=0; i<10; ++i){
        for( j=0; j<10; ++j){
            //....
        }
    }
    // 这行以下,i和j都有效。
}

C++允许变量需要时才申明和定义,同时循环变量的作用域局部化。

int f(){
    for( int i=0;i<10; ++i){
        for( int j=0; j<10; ++j){
            //....
        }
        // 这行以下,j就无效了。
    }
    //这行以下,i就无效了。

}

局部化的变量有助于避免变量冲突问题。

结构体与类

你们可能已经知道C++和Java里都有类的概念。类是C++里对C语言结构体的拓展。 类不仅可以像结构体那样拥有成员变量(称为属性),也可以拥有函数(称为方法)。

例如:

成员函数

#include <iostream>
#include <cmath>
using namespace std;
class Point{
private:
    float x;
    float y;
public:
    Point(){
        x = 0;
        y = 0;
    }
    void setPoint( float x, float y ){
        this->x = x;
        this->y = y;
    }
    void printPoint() const
    {
        cout << "( " << x << ", " << y << " )" << endl;
    }
};

如果我们去掉privatepublic这两个限定词,我们可以发现Point这个类里,包含两个成员变量x,y,和三个成员函数Point,setPointdistance

除去Point,其他两个成员函数的用法与成员变量并无不同:

Point p1,p2;
p1.setPoint( 1.0f, 2.0f); // 调用p1的setPoint函数,注意,你可以认为这个函数属于变量p1
p2.setPoint( 3.0f, 4.0f); // 调用p2的setPoint函数,注意,你可以认为这个函数属于变量p2
p1.printPoint(); // 输出:( 1, 2 )
p2.printPoint(); // 输出:( 3, 4 )

在这种调用下,你会发现函数只作用于他所属的变量本身,回忆一下JavaScript里的this,体会下这种区别。

在函数内部你可以发现变量this,这是C++里用来表示当前对象的指针,作用与JavaScript的this一样。 不同的是在C++里,this是个指针,Java也有this,Java的this更接近JavaScript的版本。

访问控制

private说明其下的两个成员x,y是私有成员,这两个属性只能在内部访问(即Point的成员函数内访问),无法在外部访问。 也就是说,下面这种用法是禁止的,会导致编译错误:

Point p;
cout << p.x << "," p.y << endl; // 错误!p.x和p.y不能从外部直接访问

正确的用法是使用p公开的函数来操作他们,你注意到三个函数都处于public下,因此这是允许的:

Point p;
p.setPoint( 1.0f, 2.0f); // 可以,setPoint是public函数
p.printPoint(); // 可以,printPoint也是public函数

访问控制限制外部随意修改类内部的成员,保证类内部属性(成员变量)的安全性,更好地体现了程序的封闭性。

限定符的误区

这个例子可能会让你有“成员变量是private的,成员函数是public的错觉”。

实际上并非如此。对限定符来说,不管你是函数还是变量,都受它管辖。 也就是说,可以有private的函数,也可以有public的变量。

后者通常都是为了方便,一般不建议,前者(private的函数)其实是很常见的。

思考一下,private的函数会用在什么地方?

构造函数和析构函数

你可能注意到函数Point与众不同。首先它的名字与类名完全一致,其次它没有返回值。这种函数被成为构造函数。意思是(构造变量的时候会被调用的函数)。 因此你可以很容易发现下面这段代码的输出:

Point p;        // Point函数被调用,此时p.x和p.y都为0
p.printPoint(); // 输出:( 0, 0 )

构造函数可以带参数,我们把构造函数改成这样:

Point( float x, float y ){
    this->x = x;
    this->y = y;
}

此时你不能再用Point p来申明变量了,因为构造函数要求带两个参数。你必须这样:

Point p(1,1);
p.printPoint(); // 输出:( 1, 1 )
Point p1;       // 错误!默认构造函数不再可用

不带参数的构造函数被称为默认构造函数,所谓默认,就是可以不写。上例中,即使你不写Point()这个函数。 依然可以构造Point类型的变量,但此时你不能假定x,y等于0,对于C++来说,此时他们的值是Undefined Behaviour,你可以理解成,可以是任意值

Undefined Behaviour of C/C++

行为未定义是C/C++的特色,不可不品尝。你们可以自行STFW,仔细体会一下,为什么要特别定义一种没有规定的行为?

注意默认构造函数的规则:

  1. 如果类没有定义其他带参数的构造函数,编译器就帮你生成一个什么都不做的默认构造函数,以免你无法实例化变量。
  2. 如果定义了其他带参数的构造函数,编译器就不再生成默认构造函数了,此时采用默认的构造方式会导致编译错误。

与构造函数对应的是析构函数。析构函数的函数名由构造函数前加~组成。

class Point{
    //.....
    ~Point(){    // 析构函数
    }
};

析构函数在变量被销毁的时候调用。 一个变量在什么时候被销毁呢,可以简单分为几种情况:

  1. 局部变量,局部变量在退出函数的时候销毁。
  2. 通过new动态分配的变量,new出来的变量在delete的时候销毁。
  3. 静态和全局变量,这类变量在整个程序结束前销毁(而且顺序是不固定的!谨记这一点!)。

自己编写代码体会一下析构和构造函数

自己改写一下Point这个类,在构造和析构的时候输出信息,自己看看他们会怎样调用。 你可以借助调试器来观察调用的时机。

思考一下:为什么我们会需要构造函数和析构函数?

结构体与类

一般人可能会觉得,类可以带函数,因此类比结构体高级。事实上并非如此,结构体一样可以带函数, 把上例中的class替换成struct,你会发现代码照样可用,所有的规则也都一样。 对于C++来说,classstruct其实是一种东西,没有区别

那为什么还需要两种不同的类型呢?两者有何区别?

先说区别。当你不使用限定符时,struct默认成员都是public的。classprivate的。

也就是说:

class C{
    int x;  // 是private的
};

struct S{
    int x; // 是public的
};

现在可以回答第一个问题了,为什么需要有classstruct两种不同的东西。

原因很简单,class才是C++语言设计中心目中类的完全体。因此是struct去将就class,而不是反过来。

那么为什么又需要一个struct呢?聪明的你应该已经想到了,为了兼容性。 从名字上看就知道C++的设计初衷是成为C语言的一个超集(++)。因此它需要尽可能的兼容C的旧代码。 我们可以发现在这点上,C++做得很成功。即便从设计的哲学上看C++和C语言可以说是两个极端,完全不同, 但单从语法和使用上,两者的差别非常小,这也是为何我们可以通过一个短短的章节来介绍C++的原因(因为你们都学过C语言了)。

从这个角度来说,保留struct和让struct的成员默认是public的这两点,就非常符合逻辑了。

成员函数的定义

成员函数有两种定义方法,第一种是在类定义里直接:

class Point{
    //...
public:
    // 直接定义
    void setPoint( float x, float y ){
        this->x = x;
        this->y = y;
    }
    //...
};

另一种写法是在类里申明,在类外面定义,这种方式对尺寸比较大的函数比较友好,不会把类的定义拉得很长,不方便阅读:

class Point{
    //...
public:
    //只申明,没有定义
    void setPoint( float x, float y );
    //..
};

// 在类外面定义,用::表示setPoint是Point的成员
void Point::setPoint( float x, float y){
    this->x = x;
    this->y = y;
}

此外另一种常见的情况是,因为类的定义必须写在.h文件里,如果你要在.cpp文件里定义函数,那么只能采用第二种方式。

这两种定义方式其实是有区别的,在类里定义默认会认为是inline函数,在类外定义则不会。但对我们的课程暂时不需要区分这两者。


  1. 事实上,软件工程行业为了兼容旧代码所做的很多事,比这恶心的多了去了。 ↩︎