C++是业内一门久负盛名的计算机语言,从C语言发展起来的它,不仅支持C语言的语法,还新添加了面向对象、泛型等特性,以及祖师爷本贾尼博士补充C语言的不足。
这一篇我们先来讲讲C++对C语言不足部分的补充。
Cpp能很好的支持C,所以在Cpp文件中写C语言程序是没问题的。
从这段代码里,我们好像看不出什么问题,唯一觉得奇怪的是,为什么整型变量要取名为rand这么奇怪。我们接着往下看:
将变量rand放到全局中,报错信息给出rand重定义,我们想起C语言库里定义有一个叫rand函数与我们定义的rand变量命名冲突了,于是报出了这个语法错误。
那么上面为什么放在局部中没有报错呢?这是因为局部和全局都有时,局部优先!
到这里读者可能会说,在日常写代码中,自己写的又不一定会和自己命名冲突,那么在一个大工程里,数十几个程序员的代码合并到一起,会不会冲突?
使用命名空间,将其隔离起来,这样就不冲突了,打印rand时,找的是库里的rand函数。
使用域作用限定符::,可以让编译器在找rand时,先找域作用限定符指定的域里先去找。
#include <stdio.h>
namespace name
{
//可以嵌套使用,如果一个命名空间内也有命名冲突,可以再隔离。
namespace name1
{
int a = 0;
}
namespace name2
{
int a = 1;
}
int Add(int x, int y)
{
return x+y;
}
struct Node
{
int val;
struct Node* next;
};
}
int main()
{
printf("%d\n", name::name1::a);//到name里找name1,name1里找a
printf("%p\n", name::Add);
struct name::Node node = {0};//::要加在Node前面
return 0;
}
关于namespace关键字,基本的用法和作用讲完了,还有一个较为重要分文件写声明和定义,接着往下看:
//Stack.h文件
#include <assert.h>
namespace sjr
{
typedef struct Stack
{
int* a;
int top;
int capacity;
}Stack;
void StackInit(Stack* ps);
void StackPush(Stack* ps, int x);
}
//Stack.cpp文件
#include "Stack.h"
namespace sjr
{
void StackInit(Stack* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void StackPush(Stack* ps, int x)
{
//...
}
}
将两个不同文件的声明和定义使用同名的命名空间包起来,就可以实现声明和定义分离的同时都在命名空间内。
编译器会将不同文件的同名命名空间合并在一起,一个文件里有同名的命名空间也会合并,只是我们一般不会这么写。
总结:命名空间是用来弥补C语言命名冲突的不足。有如何创建命名空间、命名空间里的变量、函数、类型都可以正常创建、可以嵌套、声明和定义分离使用同一个命名空间,编译器会合并它们。
域作用限定符是一种指定命名空间里找的方法,以上面栈为例子,我们试着创建栈并插入几个数据:
#include "Stack.h"
/*int main
{
name::Stack st;
name::StackInit(&st);
name::StackPush(&st, 1);
name::StackPush(&st, 2);
name::StackPush(&st, 3);
name::StackPush(&st, 4);
//每次使用都需要指定,日常练习这样完全没必要
}*/
//展开命名空间
using namespace name;
int main
{
Stack st;
StackInit(&st);
StackPush(&st, 1);
StackPush(&st, 2);
StackPush(&st, 3);
StackPush(&st, 4);
}
展开命名空间就可以直接使用里面的变量、函数等,但是这和头文件的展开不同,它只是将隔离拆除了,编译器会到展开的命名空间里面去找。如果展开命名空间里的定义与全局的有命名冲突,这样还是会报错!
在项目中全展开不是很好的做法,还有一种指定展开的方法:
//第一个C++代码:cout是输出,使用流插入<<,自动识别类型;endl是换行 end line;
#include <iostream>
using namespace std;//展开std(std是C++官方库的命名空间)
int main()
{ //cout 和 endl都在命名空间里
cout << "hello world"<< endl;
return 0;
}
#include <iostream>
//为了cout和endl展开整个库太坑了
using std::cout;
using std::endl;
int main()
{
int a = 0;
cout << "hello world"<< endl;
//cin也在std里,没有指定展开,需要加std::
std::cin >> a;//cin是输入,使用流提取>>,自动识别类型。
return 0;
}
总结:编译器默认不会到命名空间里找,这是解决命名冲突的基础。使用::可以单次到指定空间里找、还有using namespace std;展开命名空间,此时里面定义的所有东西都将暴露出来、而using std::cout;则只暴露std里面的cout。
在形参的后面加上一个值,就是缺省参数。Func函数里的a就是缺省参数,在调用Func函数时,如果没有传参,缺省值10将会默认赋值给a打印,否则按实际传递的值打印。
接下来我们看缺省参数更多的知识:
以上是全缺省的细节,也就是缺省值要从左往右给。
半缺省遵循缺省参数从右往左给。
这是因为,如果不遵循这个规则,对于传参有很大的歧义。比如Fun2(10);,此时这个10是传给a还是传给b是不确定的;不支持跳跃传参Fun2(,10);,总之对于半缺省,遵循以上规则。
缺省有什么用?请看以下代码:
//Stack.h文件
#include <assert.h>
namespace sjr
{
typedef struct Stack
{
int* a;
int top;
int capacity;
}Stack;
//声明和定义分离,缺省写在声明里!
void StackInit(Stack* ps, int n = 4);
void StackPush(Stack* ps, int x);
}
//Stack.cpp文件
#include "Stack.h"
namespace sjr
{
void StackInit(Stack* ps, int n)//不写缺省,但要写对应类型的形参
{
assert(ps);
ps->a = (int*)malloc(sizeof(int)*n);
ps->top = 0;
ps->capacity = 0;
}
void StackPush(Stack* ps, int x)
{
//...
}
}
//Test.cpp文件
#include "Stack.h"
using namespace sjr;
int main()
{
//不知道要多少空间
Stack st1;
StackInit(&st1);//默认开辟4个整型空间
//知道要多少空间
Stack st2;
StackInit(&st2, 100);//此时我们显示传多少,就开多大
//使逻辑清晰且知道需要多大空间时减少扩容消耗。
return 0;
}
函数声明、定义分离,缺省参数只写在声明里,不能声明定义同时写缺省,这是为了避免声明和定义缺省值不一。
比如在声明里的缺省值是10,在定义里的缺省值是20这种情况。
为什么是在声明里给缺省参数,而不是定义里给:这是因为有定义的地方一定包括声明,有声明的地方不一定有定义。
比如Test.cpp包含头文件Stack.h,如果声明里没有缺省参数,那么到StackInit这个函数的时候,编译器并不知道有没有缺省参数存在。
总结一句话:函数声明定义分离,缺省参数写在声明里。
函数重载指的是函数名相同、参数类型不同、个数不同、顺序不同的函数。
同为Add函数名的有两个,从C语言的角度看,main函数里调用的都是同一个函数,这是因为C语言不支持函数重载(仅根据函数名区分函数)。
C++区分函数不仅仅看函数名,还看函数的参数类型、个数、顺序等。C++中以上两个Add函数构成重载,main函数里两个int实参调用int加法,两个double实参调用double加法。
但是如果我们调用Add(1,2.2);时,隐式转换将产生调用歧义(即使构成函数重载,也要注意细节)。接下来我们看缺省参数属于什么类型:
Func(int a = 0)这个函数参数虽然是缺省参数,但是它的类型依旧是int,也就是所它们两个函数不能构成重载,并且传参调用有二义性。
函数构成重载的条件,类型和个数不同都好理解,顺序不同是什么意思呢?
? 总结:函数重载是C++支持的一种不同于C语言的特性,不同函数的函数名可以相同,但参数的类型、个数、顺序需要有不同的。此外函数返回值不参与构成函数重载的评判(调用时用不到),因为调用时无法根据参数列表确定调用哪个重载函数。
引用是给已经存在的变量取一个别名,也就是说,一块空间可以用多个名称表示。
c是a的别名,可以继续给a起别名,也可以给a的别名(c)起别名,都指向同一块空间(a)。并且引用必须初始化,不能更改指向。以上是引用的基本使用方法。
那么引用的作用是什么,如果仅使用这个功能是没有意思的,接下来我们看引用的应用场景:
#include <iostream>
void Swap(int* left, int* right)
{
int tmp = *left;
*left = *right;
*right = tmp;
}
//left是a的别名,right是b的别名,在函数里的改变会影响外面的a,b
void Swap(int& left, int& right)//int& left 也是int类型
{
int tmp = left;
left = right;
right = left;
}
int main()
{
int a = 10;
int b = 20;
//函数重载
Swap(&a, &b);
Swap(a, b);
return 0;
}
使用引用做参数,形式看起来比较容易,而且熟悉之后,理解起来也很容易。
我们再讲讲之前单链表需要传递二级指针的理解问题,对比使用引用和指针的区别更进一步体会引用做参数带来形式上的简便。
typedef struct SListNode
{
int val;
struct SListNode* next;
}SLNode,*PSLNode
//简写
void SListPushBack(SLNode** pphead, int x)
{
if(*pphead == NULL)
{
*pphead = newnode;//要把plist从空指针改成指向第一个结点
}
else
{
//找尾
tail->next = newnode;//
}
}
int main()
{
SLNode* plist = NULL;
SListPushBack(&plist, 1);
SListPushBack(&plist, 2);
SListPushBack(&plist, 3);
SListPushBack(&plist, 4);
}
在尾插时需要判断plist是否为空,为空则要改变SLNode*类型的变量,假如形参部分写着SLNode* phead,那就只是plist的一份拷贝,phead的改变不会影响plist,所以要传二级指针。
那么学了引用,怎么用引用传参呢?请看下面代码:
typedef struct SListNode
{
int val;
struct SListNode* next;
}SLNode,*PSLNode
void SListPushBack(SLNode*& phead, int x)
{
if(phead == NULL)
{
phead = newnode;//phead是SLNode*类型 是plist的别名,改变phead就会改变plist
}
else
{
//找尾
tail->next = newnode;//
}
}
int main()
{
SLNode* plist = NULL;
SListPushBack(plist, 1);
SListPushBack(plist, 2);
SListPushBack(plist, 3);
SListPushBack(plist, 4);
}
甚至使用上结点指针类型重名命的PSLNode创建变量,让不是很懂引用的初学者糊涂。
这以上是引用做参数的应用场景,使用指针也可以做到,引用和指针在做参数的时候都可以提高效率,只是指针用起来形式更复杂一点。
接下来说引用做返回值的应用场景:
int Count()
{
int n = 0;
n++;
return n;
}
int main()
{
int ret = Count();
return 0;
}
不知道读者有没有思考过,局部变量在出了作用域后生命周期结束。对于上面Count函数里的局部变量n,return n;的时候,Count函数调用结束。
如果返回n的话,那就相当于是访问一个被释放了的空间。其实了解过函数与栈帧的读者知道,n在结束生命周期前拷贝给一个寄存器(由于n变量较小),这个寄存器代替n作为Count的返回值赋值给ret。
也就是说传值返回会进行拷贝,和传值传参一个道理(传值传参会生成一个临时拷贝)。
上述代码使用传值返回是对的,如果使用传引用返回会是怎么样的呢?
int& Count()
{
int n = 0;
n++;
return n; //传引用返回,传n的别名返回
}
int main()
{
int ret = Count();//n的别名赋值给ret,相当于把n赋值给ret,因为n的别名指的也是n
return 0;
}
前面讲过,在return n;的时候,n就被释放掉了。于是返回n赋值给ret的应该是随机值。
如果操作系统还没清理n变量这块空间,那么仍有可能保留着1,否则会被刷成随机值,并且根据不同编译器可能还有所不同。
由于这种随机性,编译器会报出警告。因此返回会销毁的变量时,不采取传引用返回。(注意:会销毁的变量使用引用返回是错误的程序)
接着再进一步看使用引用接收引用返回(会销毁的变量)会如何。
ret是n的别名,相当于ret指向n那块释放了的空间。那么第一次打印取决于n的空间有没有被清理、取决于是什么编译器,因此是随机值。第二次打印的时候,我们看到那块区域已经被清理了。
这里执行了Add(3, 4);后,ret打印出来的值就变成了7。这是由于栈空间复用的原因,同一个函数或结构相似的函数连续调用,上一次的栈帧销毁后,立刻为下一次相同函数调用做准备,局部变量的地址不变。
因此ret指向的z的那块空间被第二次的Add函数调用改成7,但是打印出来的也是随机值,具体取决编译器和操作系统,VS2019打印的是7。
以上都是使用引用返回不恰当场景导致的结果,真正使用引用返回的场景是返回不会销毁的。比如malloc在堆上的对象、静态变量等等。
那么引用做返回值的价值是什么:提高效率和可以修改返回值。指针也可以做到,但形式复杂。当然引用还有指针更适合使用的场景,入门篇先不讲。
int main()
{
//权限平移
const int a = 10;
const int& b = a;
//权限缩小
int c = 20;
const int& d = c;
//引用可以是常量
const int& e = 10;
//权限放大
const int f = 5;
int& g = f;//error
c = f//c是int类型,f是const int会不会有问题?
return 0;
}
引用和指针一样存在权限缩放的问题,权限可以平移、可以缩小,不能放大。
f赋值给c是没问题的,它是值拷贝(不存在权限问题),c和f不属于同一块空间,改变c不影响f。
引用和指针的区别:引用是别名,不开空间,指针存储变量的地址;引用必须初始化,并且不能更改引用对象,指针可以不初始化,也可以更改指向;引用没有空引用,指针有空指针;
内联函数的关键字是inline,这是用来替代宏函数的。使用内联函数可以使代码量少的函数在调用处展开,避免栈帧创建和销毁的损耗。
宏函数的缺点是:写法复杂(括号较多);宏在预处理阶段就进行替换了,不能调试;没有类型检查;
而使用内联函数避免了宏的缺点,写法就是正常写函数一样,只需在函数返回类型前加inline就变为内联函数,可以进行调试,也有类型检查。
这里即使Add函数很短,调试进入反汇编还是选择调用,而不是像宏一样展开的原因是,Debug版本下默认内联函数是不会展开的,要把属性修改一下:
设置完成后,我们再调试起来看看效果:
虽说是展开,但不是把Add函数里的代码全部放到调用处,而是编译器实现和函数逻辑一样的指令。
注意内联函数在调用处选不选择展开取决于编译器,不是加了inline关键词的函数就会展开,编译器只会展开代码量少的函数。
这是因为如果有程序员给长代码函数、循环函数、递归函数加上内联,并且编译器无条件展开则会导致需要执行的指令变得非常多,生成的可执行程序文件特别大。
内联函数还有一个特别的点:
//Func.h
#include <iostream>
using namespace std;
inline void Func(int a = 10);
//Func.cpp
#include "Func.h"
void Func(int a)
{
cout << a << endl;
}
//Test.cpp
#include "Func.h"
int main()
{
Func();
return 0;
}
出现了链接错误,这是因为内联函数在编译时,Func.cpp文件包含头文件得知Func函数是内联函数后,就没有把函数的地址放进符号表,因为在链接的时候,Test.cpp文件找不到Func函数的地址。
所以当内联函数声明和定义分离时,使用只能在定义的那个文件里使用。
正确的使用内联函数的方法是,将内联函数完整的实现放在头文件中,这样函数就可以在调用的地方直接展开,而不用在链接时候找地址。
auto是C++一个用来自动推导类型的关键字,它的用途是对长类型的省略写法。
对于指针的写法可以写auto* d = c;,而对于引用我们只能显示写,以上只是说明atuo的用法,对于这种短类型,不是auto的真正使用场景。
注意:auto不能做参数类型,也不能做函数返回类型以及不能用来创建数组。
对于数组的遍历,C语言使用求数组下标依次遍历,C++使用一个更为简便的语法:
for(auto e: 数组)这个语法中e是一个和数组元素类型一样的临时变量,将数组里的值依次取出赋值给e,自动判断结束。习惯使用auto当e的类型,让其自动推导类型。
当e作为数组里每个元素的别名时,对其进行修改会影响数组里的元素。
由于冒号后面加的是数组,因此:
#include <iostream>
void Func(int arr[])
{
for(auto e : arr)//error,arr是首元素地址不是数组
{
//...
}
}
int main()
{
int arr[3] = {1,2,3};
Func(arr);
return 0;
}
在C++程序中,使用nullptr当做空指针,C语言的NULL有点错误,因此C++委员会后来补上这个坑,引入nullptr这个关键词,实质是void*。
好了,以上就是C++入门篇,希望读者有所收获!