预备一 C++的最小子集
预备一 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理解 其中包括了基本介绍和很重要的重定向概念,事实上重定向对你们刷题有很大的帮助。
题目虽然写的是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;
这依赖于一种叫函数重载(以及,运算符重载)的机制。
这点是cout
比printf
方便的地方。但相应的,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"。相对来说,cin
比scanf
函数要好用很多,唯一的缺点是速度比较慢,和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.h | cstdio |
stdlib.h | cstdlib |
string.h | cstring |
assert.h | cassert |
同时,注意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*
,调用string
的c_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;
}
};
如果我们去掉private
和public
这两个限定词,我们可以发现Point
这个类里,包含两个成员变量x,y
,和三个成员函数Point,setPoint
和distance
。
除去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,仔细体会一下,为什么要特别定义一种没有规定的行为?
注意默认构造函数的规则:
- 如果类没有定义其他带参数的构造函数,编译器就帮你生成一个什么都不做的默认构造函数,以免你无法实例化变量。
- 如果定义了其他带参数的构造函数,编译器就不再生成默认构造函数了,此时采用默认的构造方式会导致编译错误。
与构造函数对应的是析构函数。析构函数的函数名由构造函数前加~
组成。
class Point{
//.....
~Point(){ // 析构函数
}
};
析构函数在变量被销毁的时候调用。 一个变量在什么时候被销毁呢,可以简单分为几种情况:
- 局部变量,局部变量在退出函数的时候销毁。
- 通过
new
动态分配的变量,new
出来的变量在delete
的时候销毁。 - 静态和全局变量,这类变量在整个程序结束前销毁(而且顺序是不固定的!谨记这一点!)。
自己编写代码体会一下析构和构造函数
自己改写一下Point
这个类,在构造和析构的时候输出信息,自己看看他们会怎样调用。 你可以借助调试器来观察调用的时机。
思考一下:为什么我们会需要构造函数和析构函数?
结构体与类
一般人可能会觉得,类可以带函数,因此类比结构体高级。事实上并非如此,结构体一样可以带函数, 把上例中的class
替换成struct
,你会发现代码照样可用,所有的规则也都一样。 对于C++来说,class
和struct
其实是一种东西,没有区别。
那为什么还需要两种不同的类型呢?两者有何区别?
先说区别。当你不使用限定符时,struct
默认成员都是public
的。class
是private
的。
也就是说:
class C{
int x; // 是private的
};
struct S{
int x; // 是public的
};
现在可以回答第一个问题了,为什么需要有class
和struct
两种不同的东西。
原因很简单,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
函数,在类外定义则不会。但对我们的课程暂时不需要区分这两者。
事实上,软件工程行业为了兼容旧代码所做的很多事,比这恶心的多了去了。 ↩︎