本篇主要讲解C++弥补C语言的不足,以及C++如何对C语言不合理的地方进行优化
目录
1. 命名冲突问题
在C与C++中,变量、函数、类的名称都存在全局作用域中,可能导致很多冲突:比如说
- 与库里的命名冲突
- 在一个项目中,每个人开发不同部分,最后程序合并时,工作人员互相冲突
使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或者名字污染,namespace关键字的出现就是针对这种问题。
#include<stdio.h>
#include<stdlib.h>
int rand = 10;
int main()
{
printf("%d\n",rand);
return 0;
}
C语言无法解决类似这样的命名冲突问题,所以C++提出了namespace来解决。
1.1 各种域
C语言中常说全局变量,局部变量,其实C与C++一样,也有这种变量,全局变量所在为全局域,局部变量所在为局部域。而命名空间也会有一个命名空间域
#include <cstdio>
int a = 0; //全局域
namespace zzy //命名空间域
{
int a = 1;
}
using namespace zzy; //展开命名空间
int main()
{
int a = 2; //局部域
printf("%d\n",a);
return 0;
}
其中的展开命名空间,其实就是将其中的内容暴露到全局。
注意三不同域可以存在同一变量,如果同时存在时,printf首先会考虑局部域中的a,其次是全局域。如果我们将局部域中的a注释掉,而保留展开命名空间和全局域,则会出现报错,因为命名空间域中的内容被暴露到全局,而全局域本来就有变量,此时同一变量会发生冲突。
如果我们不展开命名空间,那么printf不会主动进入namespace中搜索变量a,相当于一个独立的空间。
所以自动搜索变量的顺序即为:局部域-->全局域-->展开命名空间域or指定访问命名空间域。下面代码演示如何指定访问命名空间域:
int b = 0;
namespace zzy2
{
int b = 1;
}
int main()
{
int b = 2;
printf("%d\n",b); 2
printf("%d\n",::b); 0
printf("%d\n",zzy2::b); 1
return 0;
}
::为域操作符,前面什么都不写则表示全局域,前面写namespace的名字即为命名空间域。这种方法还是比较好的。总之,不要轻易展开命名空间。
1.2 命名空间定义
定义命名空间,需要使用namespace关键字,后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员。
其一:命名空间中可以定义变量/函数/类型
namespace zzy3
{
int rand = 20;
int ADD(int left, int right)
{
return left+right;
}
struct Node
{
struct Node *next;
int val;
};
}
其二:命名空间可以嵌套
namespace zzy4
{
int a;
int b;
int ADD(int left, int right)
{
return left+right;
}
namespace zzy
{
int c;
int d;
int Sub(int left, int right)
{
return left-right;
}
}
}
调用方法:
int main()
{
printf("%d\n",zzy4::a);
printf("%d\n",zzy4::zzy::c);
printf("%d\n",zzy4::ADD(1,2));
printf("%d\n",zzy4::zzy::Sub(2,1));
}
其三:同一个工程中允许有多个相同名称的命名空间,编译器最后会合成同一个命名空间中。同一个工程中的test.h和test.cpp中同一个namespace会合并,都可以调用,但是,相同命名空间中不能定义同一个变量。
注意:一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中。
1.3 命名空间的使用
命名空间的使用有三种方式:
- 加命名空间名称及域限定符
namespace zzy
{
int a;
int b;
int ADD(int left, int right)
{
return left+right;
}
}
int main()
{
printf("%d\n",zzy::a);
}
- 使用using将命名空间中的某个成员引入
using zzy::b;
int main()
{
printf("%d\n",zzy::a);
printf("%d\n",b);
}
- 使用using namespace命名空间名称引入
using namespace zzy;
int main()
{
printf("%d\n",zzy::a);
printf("%d\n",b);
ADD(20,30);
return 0;
}
2. C++输入和输出
C语言中第一个输出的往往是,hello world,那在C++中如何来实现这种输出呢?
#include <iostream>
using namespace std;
int main()
{
cout << "Hello World"<<endl;
return 0;
}
std是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中。
要注意:iostream中是有std的定义,而using namespace是说明是否去搜索,也就是说,只使用using,没有前面的定义是没有用的。
说明:
- 使用cout和cin时,必须包含<iostream>头文件以及按照命名空间使用方法使用std。
- endl是特殊符号,其实就是endline,表示换行输出,也包含在iostream中。
- <<是流插入运算符,>>是流提取运算符。
- 使用C++输入输出是更方便的,不需要像printf一样手动控制格式,C++的输入输出可以自动识别变量类型。
#include <iostream>
using namespace std;
int main()
{
cout << "Hello World"<<endl;
int a;
double b;
char c;
cin>>a;
cin>>b>>c;
cout<<a<<endl;
cout<<b<<" "<<c<<endl;
return 0;
}
连续输入b、c时,中间打空格。
std命名空间的使用惯例:
- 在日常练习中,建议直接using namespace std即可,这样就很方便。
- using namespace std展开,标准库就全部暴露出来了,如果我们不小心定义了与库中重名的变量,就存在冲突问题,在项目比较大,代码比较多时就容易出现。所以建议在项目开发中使用std::cout这样展开常用库对象。
- 指定展开,个人认为是最好用的一种,可以省去写std::cout这样麻烦的方式,又不用展开全部的库:
using std::cout;
using std::endl;
3. 缺省参数
3.1 缺省参数的概念
缺省参数是声明或者定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。
using namespace std;
void Func(int a = 0)
{
cout << a << endl;
}
int main()
{
Func();
Func(10);
return 0;
}
没有传参时,使用参数默认值,传参时,使用指定的实参。
3.2 缺省参数的起源
struct Stack
{
int *a;
int top;
int capacity;
};
void StackInit(struct Stack *pst, int defaultcapacity = 4)
{
pst->a = (int*)malloc(sizeof(int)*defaultcapacity);
if(pst->a == NULL)
{
perror("malloc fail");
return;
}
pst->top = 0;
pst->capacity = defaultcapacity;
}
int main()
{
struct Stack st1;
StackInit(&st1,100);
struct Stack st2;
StackInit(&st2);
return 0;
}
如果不写缺省参数=4,在不知道要插入多少个数据的情况下就没办法开辟合适的空间,要么大了要么小了。如果写了缺省参数,在知道的情况下可以传100,覆盖掉默认的缺省参数,而不传则会按照默认容量进行开辟。
在C语言中实现这种效果是通过#define 默认容量 4,如果以后想要修改的话,还得到定义处修改。远不如C++中的缺省参数灵活。
3.3 缺省参数的分类
- 全缺省参数
using namespace std;
void Func(int a = 10, int b = 20, int c = 30)
{
cout << "a= " << a << endl;
cout << "b= " << b << endl;
cout << "c= " << c << endl;
}
int main()
{
Func();
Func(1);
Func(1,2);
Func(1,2,3);
// Func(1,,3);
}
传参是从左向右传参,中间不能有间隔,会报错。
- 半缺省参数
void Func(int a, int b = 20, int c = 30)
{
cout << "a= " << a << endl;
cout << "b= " << b << endl;
cout << "c= " << c << endl;
}
注意:
- 半缺省参数必须从右向左依次给出,不饿能间隔着给,即有a,a、b,a、b、c三种缺省情况,所以传参是从左向右一一对应。
- 缺省参数不能在函数声明和定义中同时出现。
//a.h
void Func(int a = 10);
//a.cpp
void Func(int a = 20);
如果声明与定义位置同时出现缺省参数,恰巧两个位置提供的值不同,那编译器就无法确定到底该用哪个缺省值。
由于先进行编译阶段,所以我们要在声明中给定缺省参数,在定义中给缺省是没有必要的。
4. 函数重载
自然语言中,一个词有多种含义,人们可以通过上下文来判断该词真正含义,即该词被重载了。
4.1 函数重载的概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。
C语言不允许同名函数的存在。
- 参数类型不同
#include <iostream>
using namespace std;
int Add(int left, int right)
{
cout << "int Add(int left, int right)" << endl;
return left+right;
}
double Add(double left, double right)
{
cout << "double Add(double left, double right)" << endl;
return left+right;
}
- 参数个数不同
void f()
{
cout << "f()" << endl;
}
void f(int a)
{
cout << "f(int a)" << endl;
}
需注意:
void f()
{
cout << "f()" << endl;
}
void f(int a = 0)
{
cout << "f(int a)" << endl;
}
这样虽然构成重载,但是是有问题的,无参调用时,会出现冲突,上下两个同名函数会出现歧义,f()到底是调用第一个,还是调用第二个但是使用默认值。
- 参数类型顺序不同
void Func(int a, char b)
{
cout << "Func(int a, char b)" << endl;
}
void Func(char b, int a)
{
cout << "Func(char b, int a)" << endl;
}
int main()
{
Add(10,20);
Add(10.1,10.2);
f();
f(100);
Func(10,'b');
Func('b',20);
return 0;
}
需注意是类型顺序不同,而不是类型名称不同:
void Func(int a, char b);
void Func(int b, char a);
这种是不构成重载的。
4.2 C++支持函数重载的原因--名字修饰
为什么C++支持函数重载,而C语言不支持函数重载呢?
下面我们来分析一个程序运行起来的过程,预处理、编译、汇编、链接
1.实际项目通常是由多个头文件和源文件构成,在test.cpp中调用了函数定义.cpp中定义的函数时,只是有.h中的声明,声明只是相当于一个承诺,但是这个承诺还没有兑现。
2.链接阶段就是专门处理这种问题,用来兑现承诺,而链接时就会按某种特殊的名字去寻找函数。
在C语言编译器编译后:
函数的名字的修饰不变,还是函数本身的名字。
在C++编译器编译后:
函数的名字的修饰发生改变,变成【Z+函数名称长度+函数名+函数参数类型首字母】
3. 到这里就理解了C语言没法支持重载,因为同名函数没办法区分,而C++是通过函数修饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载。
4.如果两个函数函数名和参数是一样的,返回值不同是不构成重载的,因为调用时编译器没办法区分。
5. 引用
5.1 引用的概念
引用不是新定义一个变量,而是给已存在的变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用一块内存空间。
void Test()
{
int a = 10;
int& ra = a; 定义引用类型
cout << a << endl;
cout << ra << endl;
}
int main()
{
Test();
}
类型& 引用变量名 = 引用实体;
ra是的别名,两者都是同一块空间,ra++,a也++。注意:引用类型和引用实体是同种类型的。
5.2 引用特性
- 引用在定义时一定要初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,那么再不能引用其他实体
void Test2()
{
int a = 10;
int& ra = a;
// int& ra; 该条语句编译时会出错
int& rra = a;
cout << a << endl;
cout << ra << endl;
cout << rra << endl;
}
5.3 常引用
void Test3()
{
const int a = 10;
// int& ra = a; 出错,因为a是常量
const int& ra = a;
// int& b =10; 出错,因为b是常量
const int& b =10;
double d = 10.2;
// int& rd = d; 出错,因为类型不同
const int& rd = d;
}
那为什么会有这几种结果呢?
首先来看常量情况,a是常量,且被const修饰,即为不可修改,此时若是定义int& ra = a;是将ra定义为a的别名,而ra其实就是a(共享同一块空间),关键就在于没有用const修饰,导致定义的ra是可变的,这与a是const修饰过的不可变的情况不相符。所以会出错
其次,我直接定义10的别名是b也是异曲同工,应该用const进行修饰。
最后,double型的变量再引用时,变成int型,是隐式类型转换,类型转换就会创建临时变量,而临时变量具有常性(即不可修改),加上const说明rd是不可修改的,和临时变量的权限就匹配了。
下面我们来深度理解一下权限:
void Test4()
{
//1.
const int a = 0;
// int& b = a;
//2.
const int c = 1;
int d = c;
//3.
int x = 0;
int& y = x;
const int& z = x;
x++;
// z++;
}
第一块代码中,int& b = a是不可以的,a已经被修饰不可改变了,再引用成可以修改的b是将权限进行放大,如果这样做,修改b相应的也会改变a。
第二块代码中,c拷贝给d,没有放大权限,因为修改d并不影响c。
第三块代码中,可以,int& y = x是将权限平移,const int& z是缩小权限,因为x可读可写,而z只可读,不可更改,其实是缩小了z作为别名的权限,z++是不可以的,而x++是可以的。
最后我们来看一下,传值调用的情况:
int func1()
{
static int x = 0;
return x;
}
int& func2()
{
static int x = 0;
return x;
}
int main()
{
// int& ret1 = func1(); 权限放大
const int& ret1 = func1(); 权限平移
int ret1 = func1(); 拷贝
int& ret2 = func2(); 权限平移 别名再取别名
const int& rret2 = func2(); 权限缩小
}
传值调用也会创建临时变量,所以也要用const修饰,而func2的返回类型是int&,我们接下来马上会在使用场景中见到,引用做返回值及其优点。
5.4 使用场景
- 做参数
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
交换函数传值肯定不行,以前参数都是传指针,现在可以引用做参数,引用也是同一块空间。
引用做参数可以提高效率,体现在大对象和深拷贝上,大对象就是比较大的对象,通常是占用比较大的空间,而深拷贝我们目前暂不做解释。
- 做返回值
int& Count(int x)
{
// static int n = x;
int n = x;
n++;
return n;
}
int main()
{
int& ret = Count(10);
cout << ret << endl;
Count(20);
cout << ret << endl;
return 0;
}
这种情况下,ret是n的别名,而count栈在调用都会销毁,n的空间虽然还在,但已经无法访问到,那为什么还会显示11呢?这个就是老生常谈的问题,ret访问的是销毁空间的变量,不清理空间则是11,是不会变的,但实际上这样调用是非常危险的。
如果加上static:
int& Count(int x)
{
static int n = x;
// int n = x;
n++;
return n;
}
n在静态区,count的栈帧的销毁不会影响n,若不传引用,不管n在哪个区,还会创建临时变量。
总之,若没有static,不能使用引用返回,此时结果不确定。
下面来看一种特殊情况:
#include <iostream>
using namespace std;
int& Add(int a, int b)
{
int c = a+b;
return c;
}
int main()
{
int& ret = Add(1,2);
Add(3,4);
cout << "Add(1,2) is : " << endl;
return 0;
}
Add函数第一次运行后,该函数对应的栈空间就被回收了,即c变量就没有意义了,在main中ret引用Add函数返回值,实际应用的就是一块已经被释放的空间。
Add函数第二次运行后,原本c的空间被修改为7,因此ret的值就改变了。
注意:如果函数返回时,出了函数作用域,如果返回对象还在,则可以使用引用返回;如果已经返还给了系统,则必须使用传值返回。
5.5 传值,传引用效率比较
以值作为参数或者返回值类型时,在传参和返回期间,函数不会直接传送实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时拷贝,因此,传值效率低下,尤其是当参数或者返回值类型非常大时,效率就更低。
5.5.1 值和引用作为返回值类型的性能比较
5.6 引用和指针的区别
在语法概念上,引用就是一个别名,没有独立空间,和其引用实体公用同一块空间。
int main()
{
int a = 10;
int& ra = a;
cout<<"&a = "<<&a<<endl;
cout<<"&ra = "<<&ra<<endl;
return 0;
}
&a = 0xe103fff974
&ra = 0xe103fff974
在底层实现上,实际是有空间的,引用是按照指针方式来实现的。
引用和指针的不同点:
引用 | 指针 |
定义一个变量的别名 | 存储一个变量地址 |
在定义时必须初始化 | 没有要求 |
引用实体后,不能引用其他实体 | 任意指向同类实体 |
没有NULL引用 | 有NULL指针 |
引用结果为引用类型的大小 | 始终是地址空间所占字节个数 |
引用自加即引用的实体加1 | 指针向后偏移一个类型的大小 |
没有多级引用 | 有多级指针 |
编译器自己处理 | 需要显示解引用 |
更安全 | 比较危险 |
6. auto关键字(自动推导)
6.1 类型别名思考
随着程序越来越复杂,程序中用到的类型也就越来越复杂,目前还没什么感觉,之后经常体现在:
1.类型难于拼写
2.含义不明确导致出错
例如:std::string>::iterator 就是一个类型,但是它太长了,容易写错。如果使用typedef确实可以简化代码,可是在编程时,常常需要把表达式的值赋值给变量,这就要求在声明变量时清除的知道表达式的类型。
6.2 auto简介
在C++11中,auto作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译时期推导而得。
int TestAuto()
{
return 10;
}
int main()
{
int a = 10;
auto b = a;
auto c = 'a';
auto d = TestAuto();
cout<<typeid(b).name()<<endl; i
cout<<typeid(c).name()<<endl; c
cout<<typeid(d).name()<<endl; i
return 0;
}
auto会根据右边的表达式自动推导类型,typeid意为打印类型。
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型,因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
6.3 auto使用规则
- auto与指针和引用结合起来使用
用auto声明指针类型时,用auto还是auto*没有任何区别,但是auto声明引用类型时则必须加&
int main()
{
int x = 10;
auto a = &x;
auto* b = &x;
auto& c = x;
cout<<typeid(a).name()<<endl;
cout<<typeid(b).name()<<endl;
cout<<typeid(c).name()<<endl;
return 0;
}
- 在同一行定义多个变量
在同一行定义多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0;
}
第二行会编译失败,c和d的初始化表达式类型不同
6.4 auto不能推导的场景
- auto不能作为函数的参数类型
- auto不能直接用来声明数组
- 与新式for循环,lambda表达式等配合使用
7. 基于范围的for循环
7.1 范围for的语法
在C++98中要遍历一个数组,可以按照如下的方法:
void TestFor()
{
int arr[] = {1,2,3,4,5};
for (int i = 0; i<sizeof(arr)/sizeof(arr[0]); ++i)
{
arr[i] *= 2;
}
for(int* p = arr; p<arr+sizeof(arr)/sizeof(arr[0]); ++p)
{
cout<<*p<<endl;
}
}
但是对于这种有范围的数组,或者说一个集合,我们来说明循环范围是多余的,因此在C++11中引入了基于范围的for循环:
void TestFor2()
{
int arr[] = {1,2,3,4,5};
for (auto& i : arr)
{
i *= 2;
}
for (auto i : arr)
{
cout<< i <<" ";
}
}
int main()
{
TestFor2();
}
for循环后的括号由冒号:分为两部分,第一部分为范围内用于迭代的变量,第二部分表示被迭代的范围。
7.2 范围for的使用条件
- for循环迭代的范围必须是确定的
对于数组而言,就是第一个元素与最后一个元素的范围
- 迭代的对象要实现++和==的操作。
8. 内联函数
在C语言中定义一个比较简短的函数可以使用宏函数的方法,宏是替换,不是调用,比如两个数的和
#define Add(x,y) ((x)+(y))
宏函数的优势是不需要建立栈帧,可以提高效率,缺点是复杂,易出错,可读性差,不能调试。
那么怎么解决这个问题呢?即有一种函数,有宏函数的优点又没有宏函数的缺点。
内联函数就是这样一种函数,接下来我们来看一下他的概念和特性。
8.1 概念
以inline修饰的函数称为内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立的栈帧的开销,内联函数提升程序运行时的效率。
查看方式:
1.在release模式下,不方便查看汇编代码。
2.在debug模式下,需要对编译器进行设置,否则不会展开(因为默认debug模式下,编译器默认不会对代码进行优化,即inline不会起作用)。
inline int Add(int left, int right)
{
return left+right;
}
int main()
{
int ret = 0;
ret = Add(1,2);
return 0;
}
8.2 特性
- inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
- inline对编译器而言只是一个建议,一般建议:函数规模较小(5行以内)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会自动忽略inline特性。
其实一个复杂的函数不大可能在调用处频繁展开,举个例子,一个50行的函数,在程序中有10000个位置调用它,假如他是内联函数,那么就会有10000*50行代码,导致代码膨胀,会使可执行程序变大(安装包)。
3. inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到,所以直接将函数写在.h文件中。
9. 指针空值nullptr(C++11)
9.1 C++98中的指针空值
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针,如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化:
void TestPtr()
{
int* p1 = NULL;
int* p2 = 0;
}
NULL其实是一个宏,在传统的C头文件中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void*)0)
#endif
#endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都会遇到一些歧义:
#include <iostream>
using namespace std;
void f(int)
{
cout << "f(int)" << endl;
}
void f(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
f(0);
f(NULL);
f((int*)NULL);
return 0;
}
f(NULL)本来是想调用f(int*)函数,但是由于NULL被定义为0,因此与城西的初衷相悖。
所以我们引入了nullptr来代替int*型指针NULL,避免歧义定义为0。
f(int)里形参接不接受都可以,只匹配类型也可,这里就算接收了也是没什么用的。
注意:
- 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入
- C++11中,sizeof(nullptr) 与sizeof((void*)0)所占的字节数相同。
- 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。
本章完