初尝C++的世界

2,470 阅读17分钟

更多博文,请看音视频系统学习的浪漫马车之总目录

C++语法重点难点:

初尝C++的世界

进一步走进C++面向对象的世界

感受C++一些令人眼前一亮的语法

前面几篇讲C语言的博文,很少讲到语法的东西,重点在于内存指针方面的讲解,主要是因为C语言的语法比较简单,对于编程老司机来说可能都很熟悉了(即使没学过C语言),再说就是废话了。但是对于C++语法来说,编程老司机们可能就不那么淡定了,所以关于C++的讲解,会讲到很多C++语法点,当然C++语法内容非常庞大,几篇文章不可能讲得完,只能从精华去讲,并力求从更本质的方向去说明我就先假设各位是写过Java的,即以一个Java程序猿学习C++的角度来写,因为Java可以看做简化版的C++(c++ --),所以相信各位老程序员对于一些细枝末节的语法内容已经不在话下了(大不了百度谷歌快速搞定)。大家坐稳扶好,我们继续前进。

C++相对于C的升级

所谓C++,顾名思义,就是在C的基础上++,即升级版C语言,升级在哪里呢,首当其冲,便是面向对象的编程思想。我们老是说面向对象,那到底啥才是面向对象呢,可能众多编程老司机不一定有很明确的理解。

C语言就是典型的面向过程的语言(虽然结合struct也可以写出具有面向对象思想的程序),顾名思义,就是编程程序的主体思想是按照一个过程一步步执行的,面向过程主要基于函数去封装过程,所以代码的复用也一般基于函数。函数本质就是对一些列操作的包装,所以面向过程的语言就是一步步执行一个个函数直到完成功能。

而面向对象编程,其实本质还是执行一个个函数,但是核心在于将被执行的非原始类型数据封装为一个个类型,这其实和C的struct是一样的,但是面向对象编程的类型是包含函数(一般叫做方法)的,也就是函数归一种类型 所有了,这样将数据状态和操作状态的函数绑定在一起了,这样就拥有一个个具体而生动的事物种类了,就像鸟拥有翅膀的“数据状态”又拥有飞这种”函数“,老虎拥有发达的手臂”数据状态“又有搏斗这个”函数“,分类更加清晰且更加符合人的思维习惯了。为什么说是更符合人的思维习惯呢?因为自从拥有了面向对象的编程方式,因为一个个不同类型的事物的状态和方法被封装得很好,写代码的人可以把编写程序变得类似组装电脑一样了,把主机、显式器和键盘组装在一起就成了一套电脑设备。

面向对象编程更强大的是拥有继承的能力,从而又衍生了多态的能力,使得代码的扩展性更强大,代码的条理、类和类之间和关联更加清晰。(比如让大雁、麻雀、老鹰继承鸟类,一旦鸟需要增加功能,直接在鸟中添加则大雁、麻雀、老鹰都自动增加该类型,而调用的地方也可以传参为鸟类,等到运行时传入大雁、麻雀、老鹰的某一种再执行对应的方法,这样调用和被调用处相当于被解耦了)进而后面又演绎出了设计模式等各种将多态运用到炉火纯青的“奇技淫巧”,项目的扩展性和可复用性得到了很大程度的增强。用上面组装电脑的例子来说,就是主机、显式器和键盘可以灵活使用不同的品牌组装在一起,它们之间只认电线的接口,不会和固定的品牌绑定死

这种编程思维的质变,对于以后写大型项目提供了很大的帮助,对节省程序猿编码和调试修bug成本真的是立下汗马功劳。

类和对象:

关于类与对象,写过Java的老司机简直都不能再熟悉了,但是C++的类与对象写法虽然和Java很相近,但还是有一些微妙的不同。

根据约定俗成的规范,C++一般将类定义在头文件中,类拥有的方法仅仅声明,然后在对应的源文件中实现类的方法,比如在People.h中定义类People:

class People {
//权限符合Java略微不同,将所有该权限符的成员写在该权限符下方即可,不需要一个个
//成员变量前加权限符
private:
    int age;
	char* name;
	
    int getAge();
public:
    int money;
    //构造方法
    People(int age,int money, char* name);
    
    void show(int money);
};

在People.cpp中实现类方法:

//每个方法前的People::表示该方法属于哪个类
People::People(int age,int money, char* name) {
    cout << "People(int age, int money, char* name)" << endl;
	this->age = age;
    this->money = money;
    this->name = name;
}

void People::show(int money) {
    cout << "People::show" << money<< money;
}

int People::getAge() {
    return age;
}

当要使用该类的时候,只要导入对应的头文件People.h即可。

Java创建对象直接用一个new解决,不过C++还是略微不同,有2种方式,一种是也是用new:

People *people = new People(1);

这种和Java一样将对象创建在堆中。其实Java用的就是这种类型,我们可以把Java的引用当做简化版的指针。创建出来的对象是全局的存在,所以可以在创建它的函数之外使用。

这种创建对象可以理解为创建的对象是匿名的,要访问该对象需要一个指针对向它从而进行操作。

new的作用和之前讲过的 malloc一样都是在堆中申请内存,当然因为C++并没有垃圾回收机制,所以和Java不同的事,和 malloc要free,new一样也需要手动释放内存,使用的关键字是delete,不释放的话内存就会长久在堆中存在直至程序结束,形成了典型的内存泄漏(即无用的对象或者数据占用了内存)。

这种对象指针调用方法的方式是:

people->show(1);

另外一种对象创建方式Java老司机未必熟悉:

People people(1);

这种方式是将对象创建在栈中,特点是随函数执行结束而自动释放内存,即不能在创建它的函数之外使用。 (关于堆和栈如果不熟悉可以看下之前这篇博文:漫谈C语言内存管理

这种创建对象方式,可以看做创建出来的对象是有名字的,使用的时候是直接通过对象的名字使用的。

这种对象指针调用方法的方式是:

people.show(1);

2种方式对比来说,第1种更加灵活,可以运用多态等机制更加代码的灵活性,对象使用范围大,但是内存分配效率低。第2种方式内存分配效率更高,但是对象使用范围有限制(当前函数内)。

构造方法

说到了类和对象,那一定不能不提到构造方法,不过这个C++和Java基本是大同小异,同的部分就不说了,说下不同的部分吧,主要是多了个初始化列表的语法糖以及密封类的构造方法。

初始化列表很简单,就是把上面的

People::People(int age, int money, char* name) {
    cout << "People(int age, int money, char* name)" <<  endl;
    
    this->age = age;
    this->money = money;
    this->name = name;
}

改为:

People::People(int age, int money, char* name) :age(age),money(money),name(name), wife(age,name){
    cout << "People(int age, int money, char* name)" <<  endl;
}

很简单,没啥可以讲的。而密封类其实就是一个类内部有成员是一个类的对象,而不是简单的基本类型,比如增加一个类Wife:

class Wife{
private:
    int age;
    char* name;

public:
    Wife(int age,char* name);
    
    Wife(int age);
};

然后在People中添加一个成员:

class People {
private:
    int age;
    char* name;
    //People内部持有另一个类的对象
    Wife wife;
    
    int getAge();
public:
    int money;
    People();
    
    People(int age);
    
    People(int age,int money, char* name);

    ~People();
};

这时候,C++要求People的构造方法必须初始化这个Wife的成员对象,所以People的构造方法需要显式调用Wife的构造方法去初始化Wife对象,如下:

//注意这行末尾对wife的初始化
People::People(int age, int money, char* name) :age(age),money(money),name(name), **wife(age,name)**{
    cout << "People(int age, int money, char* name)" <<  endl;
}

这个和Java有所不同,Java成员对象并没有要求一定要在当前类对象初始化的时候初始化,可以灵活在任何需要的时候再初始化,我想C++这样做也是为了安全吧,牺牲了一些灵活性。

析构函数

关于内存管理,C++和Java的创造者秉持着的基本理念可谓是南辕北辙。C++创造者认为内存管理很重要,所以需要给程序员自己管理,而Java创造者认为也正是因为内存管理很重要,所以需要专门用一个程序(JVM的垃圾回收机制)来自动管理内存。

因为C++是程序员手动管理内存的,一旦delete一个对象,则该对象内存持有的对象等资源也要一同释放,不然就会有内存泄漏的风险。而Java因为有自动内存管理机制,所以不需要析构函数去专门手动清理内存。但是Java也并不是完全没有析构函数的,Java的finalize函数也有类似析构函数的作用,会在对象被回收之前调用,具体可以看下这篇文章:Destructor in JavaJava Destructor

C++的析构函数,每个类默认都存在,每当该对象被回收的时候就会被调用(栈对象离开了作用域或者堆对象被delete了)但是如果该类内部还有指向堆内的对象的指针的话,那就需要手动写析构函数去清理这些对象了

析构函数的格式是无参构造函数前加“~”,而且析构函数肯定是无参的,因为没有必要传参。比如上面的People类的析构函数是:

~People();

可以看到People中有一个name指针指向的字符串对象 ,所以我们在源文件堆析构函数实现如下:

People::~People() {
    cout << "~People()" << endl;
    delete name; 
}

即在People对象被回收的时候执行析构函数就会清理调name指向的对象,不然name指向的对象就可能一直偷偷“蚕食”着内存~~ 直到程序结束了,除非被外部持有了指向它的指针,由外部来释放。

运行下~额,报错了。运行到 ~People()中的delete name;就报错了,为啥呢,哦,原来是delete一块不能释放的内存区域,因为char指针指向的是存储在常量区的字符串常量,生命周期为整个程序的生命周期(具体可见 漫谈C语言内存管理),所以不能释放(当然这里只是展示一个坑,希望大家不要踩到)。

现在让People持有一个指向堆中内存的指针,比如上面的Wife对象指针,给Wife类增加一个析构函数:

Wife::~Wife() {
    cout << "~Wife" << endl;
}

然后在People的成员变量中增加:

Wife *wifePtr = new Wife(20);

然后People析构函数改为:

People::~People() {
    cout << "~People()" << endl;
    **delete wifePtr;**
}

运行下:

~People() ~Wife ~Wife

为什么只delete了一次Wife,却打印了2个“~Wife”呢(即调用2次Wife的析构函数)??

这里要说道另外一个知识点,注意点成员变量中有2个Wife成员对象,一个是指针wifePtr,一个是Wife本身的对象wife。在C++中,成员对象本身的声明周期是跟随外层类对象的生命周期的,和函数在栈上创建对象岁函数调用结束而自动释放类似。所以当People对象被释放后,wife对象也会自动被释放。

这样假如对象A持有对象B,对象B持有对象C,则在A的析构函数中释放B,在B的析构函数中释放C,形成一个递归调用,一层一层地调用释放,确保不再使用的对象可以及时释放。

引用

说起引用,Java老司机可以说是天天都有接触了,在Java中,访问一个对象只能通过引用来访问,一个引用也可以在运行中指向不同的对象,包括和声明引用的类相同以及声明引用的类的子类对象,C++的引用与之有很多相同之处,但也有微妙的不同,之前说过,Java的引用类似一个简化版的指针,那C++的引用可以看做一个比Java引用更加简化版的指针,相比C++设计者也是考虑到C++指针太过于灵活,很容易引入代码风险,所以才设计出了引用。

引用究竟是什么呢?一言以蔽之:变量的别名。引用的使用很简单:

int main()
{
  int x = 10;
  
  // ref is a reference to x.
  int& ref = x;
  
  // Value of x is now changed to 20
  ref = 20;
  cout << "x = " << x << endl ;
  
  // Value of x is now changed to 30
  x = 30;
  cout << "ref = " << ref << endl ;
  
  return 0;
}

运行如下:

x = 20 ref = 30

以上程序中,ref为x的引用,所以对ref的操作等同于对x的操作,反之亦然。

引用的作用

通过引用修改函数外部的值

那这个别名有什么神奇的作用呢?在漫谈C语言指针(一) 的“通过指针在函数内部修改函数外部变量值”一小节中曾经讲过,指针的一个作用是通过指针在函数内部修改函数外部变量值,具体例子是:

因为函数的调用是会拷贝参数来入栈的,所以如果直接传x本身,则在changeValue函数中修改的是函数中的局部变量p的值,而p与外部的x是完全独立的变量,所以外部的x并不会受到什么影响。

int changeValue(int p){
    p= 9;
    return p;
}

int main(){
    int x = 10;
    changeValue(x);
    printf("x: %d\n", x);
}

但如果改为通过指针传参,则可以修改到外部的x:

void changeValue(int* p){
    *p= 9;
}

int main(){
    int x = 10;
    changeValue(&x);
    printf("x: %d\n", x);
}

参数p在这里被改为指针,虽然指针也会拷贝一份,但是指向的同样是外部的x,则可以修改x成功。

简单点,写程序的方式简单点~这里changeValue方法完全可以改为使用引用:

int main(){
    int x = 10;
    //调用处直接传x
    changeValue(x);
    printf("x: %d\n", x);
}
//传参处直接使用引用
void changeValue(int &p){
    p= 9;
}

简单理解,引用p是传入的x的别名,既然是别名,那修改的还是x本身,所以可以修改成功。

为什么用引用就可以修改成功呢?其实引用本质上还是指针,所以我前面说到引用可以看做简化版的指针(比Java的引用HIA简化)。

通过引用避免大对象传参的拷贝

所以和指针一样,通过引用传参同样可以用在防止大对象作为参数传参导致的大对象拷贝,以提高内存效率。(可以参考漫谈C语言指针(二)中“指针变量作为函数参数”一小节)。

//如果直接使用People类型作为参数,则这里会拷贝一次p,假如People很大,则时间空间的开销大
void doSomething(People p){
    //doSomething
}

//改为People的引用,就如同指针一样,不会拷贝传入的People对象
void doSomething(People &p){
    //doSomething
}

在拷贝构造函数作为被拷贝对象的传参

这个后面再讲,本质也是避免参数的拷贝,这里不止会引起性能问题,更会引起错误。

那既然是简化版的指针,那引用和指针的区别是什么呢?

1.引用必须在定义的时候初始化,而指针不用:

int &p=a;  //it is correct
   but
int &p;
 p=a;    // it is incorrect as we should declare and initialize references at single step.

2.引用一旦初始化就不能重新指向其他变量,作为其他变量的引用,而指针可以重新赋值:

   int x1 = 10;
   int x2 = 10;
  
    // ref is a reference to x.
    int& ref = x2;
    
    //这里企图让ref指向x1
    ref = x1;
   
    cout << "x1 = " << x1 << endl ;
    cout << "x2 = " << x2 << endl ;
    x2 = 30;
    cout << "ref = " << ref << endl ;

运行结果:

x1 = 10 x2 = 10 ref = 30

可以看出,ref = x1;并不能使得ref指向x1,只是让x1的值赋值给x2,而后面 x2 = 30;仍然改变了ref 的值,可见ref 一直很忠诚,一旦宣布“效忠”一个变量,则不会再“效忠”其他变量了(类似指针常量(不可以改变指向的指针,不要和指向常量的常量指针搞混))。

3.引用不能指向NULL,而指针可以:

//直接编译报错
int &rr = NULL;

基于以上几点,可以看出,引用可以当做一个简化版、更加安全的指针,所以能使用引用地方,指针都可以使用,但是反过来就不行,比如引用就不能用来实现类似链表这种类型的数据结构(因为不能重新指向其他变量)。所以一般能使用引用的地方尽量使用引用,不然就使用指针。

内联函数

调用一个函数需要开辟栈帧,并且要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。所以调用一个函数是有时间和空间开销,如果函数本身代码很少,那可能调用函数的开销比执行函数本身的开销更大,那显然是不合理的。

所以内联函数便应运而生了。所谓内联函数,就是一个会在编译的时候像宏展开一样展开代码到调用处的函数,相当于在运行时这个函数是不存在的,把函数体直接写在调用处了,所以节省了函数调用的开销

C++中,通过在函数定义处(不是声明处)使用inline来指定一个函数是内联函数(准确来说是建议,因为具体是否内联,还要编译器决定)

//内联函数定义
inline void func(){
    cout<<"inline function"<<endl;
}

就这样,我们向编译器建议将函数func指定为内联函数,如果编译器采纳了我们的建议,则编译后的代码func调用处都会用

 cout<<"inline function"<<endl;

替代。

说到内联函数,Java老司机可能并不熟悉,Java没有可以在编码中有可以处理内联函数相关的关键字,以至于大家很容易以为Java没有内联函数,不过我专门查了下Oracle的文档,发现其实Java也是有内联函数的的——Understanding Java JIT Compilation with JITWatch, Part 1中的“Some JIT Compilation Techniques”中写道:

One of the most common JIT compilation techniques used by Java HotSpot VM is inlining, which is the practice of substituting the body of a method into the places where that method is called. Inlining saves the cost of calling the method; no new stack frames need to be created. By default, Java HotSpot VM will try to inline methods that contain less than 35 bytes of JVM bytecode.

所以Java的JIT编译技术会根据方法占用的大小决定是否指定为一个内联函数,所以这一步是在编译中自动处理了,对于 程序员来说是透明的过程。

内联函数使用的“坑”

上面说过一般是将函数声明在头文件,在对应源文件实现,但是内联函数如果这样处理则会出问题,如果对内联函数如何展开不是很了解,可能会一脸懵逼不知所以然。

如果按照上面的做法,在上面的People类增加一个内联函数run方法,如下将run声明在头文件People.h:

class People {
private:
    int age;
    int getAge();
public:
    int money;
    People(int age);
    
   void show(int age);
    
    //增加的内联函数run
    **inline void run();**
};

然后在对应的源文件People.cpp中实现方法:

inline void People::run() {
    cout << "run" <<  endl;
}

在main.cpp中调用该方法:

people2.run();

结果链接的时候挂了:

In function main':undefined reference to People::run()'

为啥链接的时候报错了呢?明明在People中有定义这个内联函数,怎么又说undefined reference了呢?

原因在于内联函数编译期间会用它来替换函数调用处,编译完成后函数就不存在了,而C++的编译是针对单个文件的,即如果在People.cpp内没有找到run的调用,编译器就会当做这个内联函数没有被调用,就会直接忽略这个函数,所以在链接的时候main.cpp自然找不到该方法,因为链接阶段该方法已经是不存在了。

C++与C的混编

C++是C的升级版,它俩的关系自然不同于一般,就像兄弟一般,那两者是不是可以在一起完成一个程序呢,即混编呢?当然是可以的,因为经过历史的沉淀,很多经典的库都是用C写的,如果C++不能和C语言混编的话,那比失掉荆州损失还大,所以C++创造者自然有考虑到这个情景,让C与C++可以混编。不过,因为C++和C在编译、链接上还是一些不同的。

比如C++类的方法在编译阶段会将this指针作为一个方法参数隐式添加到方法的参数列表中。还有C++会在编译阶段将重载的函数的函数名进行“再次重命名”,编译器根据方法调用传入的参数不同来确定具体调用哪个方法(本质上它们还是不同的函数,入口地址也不一样)。这些编译上的不同会导致混编的时候,C++调用C会出现在链接阶段找不到方法的报错,因为双方对于同一个方法的编译结果是不同的(简单来说就是针对同个方法定义,C++和C语言编译出来各一套,所以在链接阶段找不到,因为对不上)。所以2者混编需要做一些额外的处理。

举个简单例子: 在test1.h中添加一个方法:

void exchange(int *a, int *b);

然后在test1.c中实现该方法:

void exchange(int *a, int *b){
    int temp;
    temp = *a;
    *a = *b;
    *b = temp;
}

然后在main.cpp main调用该方法,运行会报错:

Linking CXX executable untitled1.exe CMakeFiles\untitled1.dir/objects.a(main.cpp.obj): In function main': F:/projects/CppDemo/untitled1/main.cpp:156: undefined reference to exchange(int*, int*)'

之所以找不到,是因为在test1.c,编译器会以C语言的方式去编译该方法,而在main.cpp的调用处,编译器会以C++方式编译,导致编译器看起来这是2个不同的方法,所以会找不到方法定义。

这里可以使用extern "C" 来处理这个问题,关于extern "C"可以看下微软的这篇文章的"extern "C" and extern "C++" function declarations"章节:

In C++, when used with a string, extern specifies that the linkage conventions of another language are being used for the declarator(s). C functions and data can be accessed only if they're previously declared as having C linkage. However, they must be defined in a separately compiled translation unit.

简单来说,就是将C语言的变量或者函数定义放在“extern "C"”的作用域下,就可以被C++链接到。那我的上面的程序的函数定义加个“extern "C"”作用域试试:

#ifdef __cplusplus 
//如果是C++调用的,则走这个if分支
extern "C" void exchange(int *a, int *b);
#else
//非C调用走这个分支
void exchange(int *a, int *b);
#endif

因为extern "C"是C++才可以使用的,这里要在预编译阶段判断是否定义了“__cplusplus”的宏,有的话才可以使用extern "C"。__cplusplus是C++预编译内置的宏,Preprocessor directives 中的“Predefined macro names”一节的表格中就有说明__cplusplus:

An integer value. All C++ compilers have this constant defined to some value. Its value depends on the version of the standard supported by the compiler:

运行下程序没问题~

这里还可以利用预编译的if语句优化下,以显得更加简洁:

#ifdef __cplusplus
extern "C" {
#endif
void exchange(int *a, int *b);
#ifdef __cplusplus
}
#endif

总结

本博文主要谈了几个C++相对于C语言的主要升级(不同)点以及二者如何进行混编,当然C++相对于C的不同还有很多,比如方法重载、默认参数、this指针、静态成员和方法等等,这些对于Java老司机来说都不在话下,这里语法层面的就不费篇幅去讲了,后面会有原理性的讲解。下一篇进一步走进C++面向对象的世界讲对面向对象进行更深入的探讨。

如果觉得本文有帮助,别忘了点赞关注哦~