CPP QUICKSTART


CPP入门

面对对象编程的三大特性

封装:把客观的事物抽象成一个类,即将数据和方法打包在一起,加以权限的区分,达到保护并安全使用数据的目的。

继承:继承所表达的是类之间相关的关系,这种关系使得对象可以继承另外一类对象的特征和能力。继承可以避免公用代码的重复开发,减少代码和数据冗余。

多态:一个接口,多种方法。程序在运行时才决定调用的函数,它是面对对象编程领域的核心概念。

输入&输出

C和CPP本身都没有提供输出和输入的专门语句,cin和cout是CPP中的标准输入输出流。类似C中使用printf和scanf需要包含<stdio.h>头文件,CPP中使用cout和cin需要包含头文件,并且使用命名空间 std。

#include <iostream>
using namespace std;
int main()
{
  int num = 0;
  //从键盘输入10
  cin >> num;//将10读入num
  cout << num << endl;//打印num
  std::cout << num << std::endl;//如未使用命名空间std,则需要指定
  return 0;
}

作用域运算符

#include <iostream>
using namespace std;
int a = 10;
void test()
{
    int a = 20;//全局变量和局部变量同名,则局部优先级高于全局
    cout << a << endl;//打印局部变量
    cout << ::a << endl;//使用作用域运算符::打印全局变量
}

命名空间

CPP中引入了namespace的概念,大型工程中通常由不同的人进行代码的编写,因此为了避免标识符(变量名、函数名、类名等)相同而造成冲突引入此概念

#include <stdio.h>
using namespace std;
namespace A
{
  	int num = 10;
}
namespace B
{
  	int num = 20;
}
int main()
{
  	cout << A::num << endl;//10
  	cout << B::num << endl;//20
  	return 0;
}

1.namespace只能在全局范围内定义,即不能在函数内部定义

2.namespace可以嵌套定义

namespace A
{
	int num = 10;
  namespace B
  {
    	int num = 20;
  }   
}
	cout << A::num << endl; //10
	cout << A::B::num << endl; //20

3.namespace是开放的,可以随时添加新成员

namespace A
{
	int x = 10;
}
namespace A
{
	int y = 20;
}

4.namespace可以存放函数,且函数可以在外部定义

namespace A
{
	int num = 10;
  void fun();
}
void A::fun()//外部定义需要加上作用域,否则为普通函数
{
  cout << num << endl;
}

5.namespace可以无名,实际无用处

6.namespace可以取别名

namespace verylongspacename
{
  int num = 100;
  void fun()
  {
    cout << "verylongspacename" << endl;
  }
}
int main()
{
  namespace shortname = verylongspacename;
	//可以通过两种形式使用
	verylongspacename::fun();
	shortname::fun();
  return 0;
}

7.通过using使用namespace,并且可以指定namespace中的具体成员,如果出现冲突,采取就近原则

namespace A
{
  int num = 10;
}
int main()
{
  int num = 1000;
  using namesapce A;
  cout << num << endl;//出现冲突,采取就近原则,输出为1000
  return 0;
}
namespace A
{
  int X = 10;
  int Y = 20;
}
int main()
{
  using A::X;//使用namespace中的具体成员
  cout << X << endl;//10
  cout << Y	<< endl;//此句语法错误 正确写法为:cout << A::Y << endl;
  int X = 20;
  using A::X;
  //如果使用namespace中的具体成员与局部变量冲突会造成编译错误 11和12行
  return 0;
}

函数重载

在C中函数名不能相同,即便参数表和函数体不同

在CPP中函数名可以相同,但参数表不能相同,参数表不同的函数,即构成函数重载关系。原理是编译器会根据函数表来为函数名进行替换,从而实现一个函数处理多种数据类型问题

同一作用域内函数名相同,参数个数不同/参数类型不同/参数类型顺序不同即构成函数重载(返回值不同不能作为

函数重载的条件,因为当函数返回值作右值时,根据左值的类型是可以确定调用哪个函数的,但是当忽略返回值,编译器就无法区分)

#include <iostream>
using namespace std;
void fun(int x)
{
  	cout << "int" << endl;
}
void fun(int x,int y)
{
  	cout << "int int" << endl;
}
void fun(int x,double y)
{
  cout << "int double" << endl;
}
/*
编译后类似转换为
void fun(int x) ----> void fun_int(int x)
void fun(int x,int y) ----> void fun_int_int(int x)
void fun(int x,double y) ----> void fun_int_double(int x,double y)
*/
int main()
{
  fun(10);//输出为:	int
  fun(10,20);//输出为: int int
  fun(10,9.99);//输出为: int double
  return 0;
}

CPP对比C的增强

1.全局变量检测增强
在C中第一行为定义,第二行为声明
在C++中编译失败

int a = 10;
int a;

2.对类型检测的增强
在C中函数形参类型可以缺省
在C++中编译失败

void fun(x)//C语言中缺省形参类型则 x 为 int 型
{
  
}
void fun(x)//C++中语法错误
{
  
}

3.类型转换的增强
在C中第七行和第八行都合法
在C++中只有第七行合法

typedef enum COLOR
{
    RED,
  	BULE,
  	YELLOW
}COLOR;
COLOR mycolor = RED;
COLOR mycolor = 10;

4.结构体的增强
在C中结构体没有使用typedef的情况下不可以省略struct关键字,而C++中可以省略
在C中结构体中不能定义函数,而C++中可以定义

#include <iostream>
using namespace std;
struct DATA
{
	int age;
	char sex;
	void fun(int age)//C中不能定义函数,CPP则可以,16行为调用方法
	{
		cout << age << endl;
	}
};
int main()
{
	struct DATA A;//C中不能缺省struct关键字
	DATA B;//C++可以缺省struct关键字
  A.fun(50);
	return 0;
}

5.增加bool类型
bool类型大小为1字节,只有两个值,true和false
C中C99标准才有bool类型,在头文件stdbool.h中

6.三目运算符的增强
C中三目运算符的返回值的是数值,为右值,不能对其赋值
C++中三目运算符的返回值为变量本身,为左值,可以对其赋值

int x = 10,y = 20;
x > y ? x : y = 100;//在C中此行语法错误因为返回的是20,CPP中返回的是y的引用
cout << y << endl;//在CPP中输出为100

const

const在C和CPP中的差异

const int a = 10;

对于上面这条语句,在C中,a的本质是变量,只读变量不能通过变量名对其赋值,并且默认是外部链接的,既然是变量,就会为其分配内存,那么可以通过内存地址来间接达到修改的目的
(只有在const修饰局部变量的时候才能通过地址修改,局部变量内存空间在栈区,可读可写。而如果const修饰的是全局变量则无法修改,全局变量的内存空间在文字常量区,只可读),修改后无论是通过变量名或者是地址访问都是修改后的值

#include <stdio.h>
int main()
{
  const int a = 10;
  a = 100;//错误
  int *p = (int*)&a;
  *p = 100;
  printf("%d",a);//通过变量名访问为修改后的值100
  printf("%d",*p);//通过地址访问为修改后的值100
  return 0;
}

​ 在CPP中,const修饰全局变量,默认为内部链接,只能作用于当前文件,定义处加上extern可转换外部链接。系统不会为其开辟空间,而是放到符号表当中,

NAME VALUE
a 10

类似于#define,是否为其分配空间取决于如何使用,1.当进行取地址操作时,系统则会为其分配内存空间,所以通过地址间接修改值只是修改内存空间的值,而不是修改符号表的值,这就造成了通过变量名访问的是符号表的值(不变),通过地址访问的是内存空间的值(修改后的值)。2.如果用变量为const修饰的变量进行初始化,系统会直接开辟空间,不会放入符号表中3.const修饰自定义数据类型,系统直接分配空间

#include <iostream>
using namespace std;
int main()
{
  const int a = 10;
  int *p = (int*)&a;
  *p = 100;
  cout << a << endl;//通过变量名访问的是符号表的值,符号表的值不会修改,为10
  cout << *p << endl;//通过地址访问的实际内存空间的值,为100
  //
  int c = 10;
  const int b = c;//通过变量进行赋值,直接开辟内存空间
  int *p = (int*)&b;
  *p = 100;
  cout << a << endl;//为100
  cout << *p << endl;//为100
  return 0;
}

为什么const比define好?
​ 1.类型。const具有类型,编译器会对其进行类型安全检查,而define无类型,在预处理阶段就完成替换,不会进行类型检查
​ 2.作用域。const的作用域为定义其函数和复合语句内部;define的作用域为定义处到文件结束
​ 3.命名空间。const可以作为命名空间成员,而define不可以


引用

引用的概念

引用是CPP引入的概念,通俗易懂的解释就是为变量取别名,基本语法为:Type& ref = val;这里的& 不是表示取地址,而是表明ref是一个引用,起标识作用,在其它地方出现则表示取地址

引用必须进行初始化,并且初始化后不能修改

#include <iostream>
using namespace std;
int main()
{
  int a = 10;
  int& b = a;//b等价于a
  b = 20;
  cout << a << endl;//输出为20
  return 0;
}

引用的本质

引用的本质是一个常量指针,由编译器内部实现。当声明一个引用Type& ref = val时,会转换为Type * const ref = &val,所以引用所占的空间大小和指针一致

int num = 10;
int &ref = num;// 等价于 int* const ref = &num;

对数组进行引用

#include <iostream>
using namespace std;
int main()
{
  //第一种方式
  int arr[5] = {1,2,3,4,5};
  int (&my_arr)[5] = arr;//[]优先级高于&,所以需要括号
  //第二种方式
  int arr[5] = {1,2,3,4,5};
  typedef int arr_five[5];//arr_five等价于int [5]
  arr_five &my_arr = arr;
}

函数中的使用

#include <iostream>
using namespace std;
void swap_by_point(int *x,int *y)//通过指针交换两个数
{
  int tmp = *x;
  *x = *y;
  *y = tmp;
}
void swap_by_ref(int &a,int &b)//通过引用交换两个数
{
  int tmp = a;
  a = b;
  b =	tmp;
}
int main()
{
  int x = 10,y = 20;
  swap_by_point(&x,&y);
  swap_by_ref(x,y);
  /*
  为什么通过引用的方式传的是int类型,而函数形参是int&?
  这是因为当函数形参为引用时,实参不是简单的传参,而是将实参绑定到形参上,也就是为引用进行初始化
  */
  return 0;
}

引用作为函数返回值

#include <iostream>
using namespace std;
int& fun()
{
  static int num = 10;
  return num;
}
int main()
{
  int &ret = fun();
  cout << ret << endl;//10
  return 0;
}

函数返回值作为左值必须是引用

#include <iostream>
using namespace std;
int& fun()
{
  static int num = 10;
  return num;
}
int main()
{
  fun() = 1000;
  int &ret = fun();
  cout << ret << endl;//1000
  return 0;
}

指针的引用

(封装一个函数,从堆区开辟空间,赋值为”hello world”)

#include <iostream>
#include <stdlib.h>
#include <string.h>
using namespace std;
void by_point(char **str)//通过指针实现
{
	*str = (char*)malloc(sizeof(char) * 10);
  strcpy(*str,"hello world");
  return;
}
void by_ref(char* &str)//通过引用实现
{
  str = (char*)malloc(sizeof(char) * 10);
  strcpy(str,"hello wrold");
  return;
}
int main()
{
  char *str = NULL;
  by_point(&str);
  by_ref(str);
  return 0;
}

常引用

引用可以减少实参传递的开销,但可能会导致实参被通过形参意外修改而改变,将引用定义为常量引用可以避免这种副作用,根据同的场景来选择,引用(可读可写)、常引用(只可读)

void fun(const int &ref)

对常量进行引用

const int &ten = 10;

缺省函数参数

CPP在声明函数原型时可以为一个或多个参数指定默认的参数值 ,在函数实参缺省的情况下则会 自动使用默认参数值

#include <iostream>
int fun(int x = 10,int y = 20)
{
  return x + y;
}
int main()
{
  cout << fun(100,200) << endl;//使用实参值,输出300
  cout << fun() << endl;//实参缺省,使用形参默认值,输出30
  cout << fun(20) << endl;//从左至右,缺省一个实参值,输出40
 return 0; 
}

函数默认参数从左至右,如果一个参数设置了默认参数,那么后面的参数都必须设置默认参数

void fun(int x,int y = 10)//合法,至少传一个实参值
void fun(int x,int y,int z = 20)//合法,至少传两个实参值
void fun(int x,int y = 10,int z)//非法,y设置了默认参数那么y后面的参数都必须有默认参数

如果函数定义和函数声明分开写,则不能同时设置默认参数, 一般在函数声明处设置

int fun(int x = 10,int y = 20);
int fun(int x,int y)
{
  return x + y;
}

缺省参数可能会和函数重载冲突

void funA(int a);
void funA(int a,int b = 10);
funA(10);//此时无法确定调用哪个函数

占位参数

占位参数没有参数名,函数体无法使用,但有数据类型,所以函数调用的时候必须给占位参数传参数

int fun(int x,int y,int)
{
  return x + y;
}
fun(10,20,30);//必须为占位参数传值

C和CPP混合编程

由于C++中有重载的概念,所以C和C++对于同一个函数经过编译后的函数名是不相同的。在C++代码中调用C函数,C++按照C++的命名方式来查找链接,就会找不到对应的函数,从而发生错误。如果想在C++中正确调用C函数,需要在头文件加上extern “C”来修饰对应的代码块,表明这段代码按照C语言的方式来编译和链接。

来看看一个例子

main.cpp

#include <iostream>
#include "add.h"
using namespace std;
int main()
{
  cout << add(10,40) << endl;
  return 0;
}

add.h

#ifndef __ADD_H
#define __ADD_H

#if __cplusplus//如果是cpp文件
extern "C"
{
#endif
  
int add(int x,int y);

#if __cplusplus
}
#endif
#endif//__ADD_H

add.cpp

int add(int x,int y)
{
  return x + y;
}

编译结果:


类(class)是面对对象语言程序设计中的概念,是C++学习中十分重要的一部分。类是对现实事物的抽象,比如人具有特征和行为,将人的特征抽象出来(年龄、性别、籍贯)叫做属性,将人的行为抽象出来(吃饭、睡觉、打游戏)叫做方法,类封装了属性和方法,将其作为一个整体,而不是面向过程中的方法与属性分离。类和普通数据类型一样,是一个抽象的概念,不占空间但有大小,只有实例化对象后,系统才会为其分配空间。成员变量占类的空间大小,而成员函数不占类的空间大小,类的空间大小计算和结构体大小一样,存在内存对齐,具体计算方法请查阅本站关于计算结构体大小的文章。

类的定义:

class Person//类名
{
  private://权限
  			string name;//属性
  public://权限
  			void show()//方法
        {
          cout << "my name is " << name << "."<< end;
        }
};

类的访问权限有三种:public(公共)、private(私有)、protected(保护),类的内部没有访问权限,所以成员可以互相访问,在类的外部才有访问权限的概念,在类的外部只有public修饰的成员才可被访问,在没有涉及继承和派生时,private和protected级别是一样的,外部均无法访问。由于类内部无权限之分,所以可以通过公共成员间接操作private和protected成员。类内部默认属性为private,而struct默认属性为public。

将变量声明为private的原因有以下几点:

  1. 访问数据的一致性。使得所有public接口均为函数,当操作类的时候,不必花时间去判断是函数还是变量。
  2. 可执行权限的划分。如果将变量声明为public,可执行权限则为可读可写,但如果通过函数来间接操作,就能实现多种执行权限,如:只可读、只可写等。
  3. 变量的合法性。一些变量具有限制,如年龄不可以是负数,因此可以通过在函数中实现安全判断,保证变量的合法性。

类的声明:

如同普通函数一样,我们可以仅声明类而暂时不定义它,这种声明被称为前向声明。它声明了这是一个类类型,但具体类成员尚不清楚,在它被声明之后定义之前被叫做不完全类型。我们可以定义指向不完全类型的指针或引用,也可以声明(但不可以定义)以不完全类型作为参数或返回类型的函数。类在被实例化之前必须被定义,而不仅仅是被声明,因为仅声明编译器不知道这个类的具体成员、具体大小,我们必须先完成类的定义,才能声明这种类型的数据成员。

class Base;//前向声明,不完全类型
Base func(void);//不完全类型做返回值
void funb(Base);//不完全类型做参数
Base *p = NULL;//指向不完全类型的指针

分文件实现类:

Main.cpp

#include <iostream>
#include "person.h"
using namespace std;
int main()
{
    Person a;
    a.setAge(10);
    cout << a.getAge() << endl;
    return 0;
}

person.h

#ifndef __PERSON_H
#define __PERSON_H

class Person
{
    private:
        int age;
    public:
        void setAge(int n);
        int getAge(void);
};

#endif//__PERSON_H

person.cpp

#include "data.h"
void Person::setNum(int n)//需要加上作用域,表明此函数为该类的成员函数,否则为普通函数
{
    num = n;
}
int Person::getNum(void)
{
    return num;
}

构造函数&析构函数

构造函数用于实例化对象时对对象的初始化,析构函数用于对象销毁前对对象的释放,编译器强制自动调用,无须手动调用,如果你不提供任何构造函数和析构函数,那么编译器会提供默认的构造函数和析构函数,但提供的函数函数体为空,不会执行任何操作。构造和析构是一个出入栈的过程,也就是说在同一作用域内,最先调用构造函数的,其析构函数最后调用,最后调用构造函数的,其析构函数最先调用。系统会对任何一个类提供三个成员:函数默认构造函数、默认析构函数、默认拷贝构造函数(浅拷贝),如果提供了有参构造,将会屏蔽系统的默认构造,但不会屏蔽默认的拷贝构造,此时用户如需要使用默认构造必须自行实现,否则报错,如果提供了拷贝构造,将屏蔽默认构造和默认拷贝构造。

拷贝构造是一种特殊的构造函数,用于同一类中旧对象初始化新对象,只有当新对象定义并初始化值为旧对象,才会调用拷贝构造。如果用户没有提供拷贝构造函数,那么编译器会提供默认的拷贝构造。如果一个类包含指针变量,并且有动态内存分配,那么这个类必须有用户实现的拷贝构造函数,否则存在浅拷贝问题。

构造函数语法

函数名和类名相同,不能有返回类型,包括void,可以有参数并且可以重载。

析构函数语法

函数名和类名相同,需要加上“~”与构造函数区分开,没有返回类型,包括void,并且没有参数不能重载。

拷贝构造函数语法:

和构造函数类似,其参数为对象引用。

#include <iostream>
using namespace std;
class Test
{
    private:
        int num;
    public:
        Test()//无参构造函数
        {
          	num = 0;
            cout << "我是无参构造" << endl;
        }
        Test(int n)//有参构造函数
        {
            num = n;
            cout << "我是有参构造" << endl;
        }
        ~Test()//析构函数
        {
            cout << "我是析构函数" << endl;
        }
  			Test(const Test &ob)//拷贝构造函数
        {
          	cout << "我是拷贝构造" << endl;
        }
};
void fun()
{
    Test a(10);
}
int main()
{
    cout << "start" << endl;
    fun();
    cout << "end" << endl;
    return 0;
}

上面代码的输出结果为:

start
我是无参构造
我是析构函数
end

可以看出无论是构造函数还是析构函数都是由系统来进行调用,在实例化a的时候,调用了构造函数,由于a在fun函数内部定义,为局部变量,在fun函数结束时,a将会被释放,此时系统调用了析构函数。

构造函数的几种调用方式:

//无参构造(隐式)
Test a;
//无参构造(显式)
Test a = Test();
//有参构造(隐式)
Test a(10);
//有参构造(显式)
Test a = Test(10);
//隐式转换的方式(只能用于只有一个成员变量,不建议使用)
Test a = 10;
//匿名对象(生命周期为当前语句)
Test(10);

explicit关键字:

explicit关键字作用于只有一个成员变量的类构造函数,它表明该构造函数是显式而并非隐式的,防止编译器进行隐式转换。

class A
{
  private:
  	int m_num;
  public:
  	explicit A(int x):m_num(x) {}
};
A a(5);//✅
A a = 5;//❌

拷贝构造函数的几种调用方式:

Test A(10);
//隐式
Test B(A);
//显式
Test B = Test(A);
//隐式转换
Test B = A;

深拷贝和浅拷贝的问题:

#include <iostream>
#include <string.h>
#include <stdlib.h>
using namespace std;
class Person
{
    private:
        char *m_name;
    public:
        Person()
        {
            m_name = NULL;
        }
        Person(const char *name)
        {
            cout << "有参构造" << endl;
            m_name = (char*)malloc(strlen(name) + 1);
            if(m_name)
            {
                cout << "申请空间成功" << endl;
                strcpy(m_name,name);
            }
        }
        ~Person()
        {
            cout << "析构函数" << endl;
            if(m_name)
            {
                free(m_name);
                m_name = NULL;
            }
        }
};
int main()
{
    Person A("Lihua");
    Person B(A);
    return 0;
}

上面代码定义了一个Person类,有一个指针成员变量和三个成员函数。主函数中实例化了对象A,并且调用了有参构造,在有参构造函数中,为指针成员变量开辟了空间,问题在于当用A为新对象B初始化时,系统调用了默认拷贝构造,默认拷贝构造函数是浅拷贝,只是单纯的拷贝值内容,此时对象A和对象B的指针成员变量都指向同一块内存空间,对象B并没有调用有开辟属于自己的空间,当调用析构函数时,就会出现对同一块空间进行两次free操作,从而产生错误,因此如果一个类包含指针变量,并且有动态内存分配,那么这个类必须有用户实现的拷贝构造函数,否则存在浅拷贝问题。

Person(const Person &ob)//深拷贝
{
  m_name = (char*)malloc(strlen(ob.m_name) + 1);
  if(m_name)
  {
    strcpy(m_name,ob.m_name);
  }
}

初始化列表

初始化列表是只能在构造函数中初始化类成员变量的一种方式。

class Test
{
  private:
  	int m_x,m_y,m_z;
  public:
  	Test(int x,int y,int z):m_x(x),m_y(y),m_z(z)//初始化列表
    {
      
    }
  	//or
  	Test(int x,int y,int z)
      :m_x(x),m_y(y),m_z(z)
    {
        
    }
}

不管你的初始化列表顺序是怎样,编译器都会按照类成员的定义顺序进行初始化,一般来说,初始化列表的顺序没有规定,但为了避免各种依赖性问题,初始化列表和成员的定义顺序因保持一致,举个例子:

class Test
{
  int x,y;
  public:
  	Test(int val):y(val),x(y){}
}

从表明上看,似乎是先用val的值初始化y,再用y的值来初始化x,但实际上,初始化的顺序按照定义的顺序,也就是说先初始化x再初始化y,初始化x的时候y的值还未被初始化为随机值,导致了x的值也为随机值。因此如果想避免这种问题,最好不要使用成员来进行初始化,而是使用构造函数的参数。

为什么要使用初始化列表?看上去像是一种代码风格,实际有特殊的原因:

  1. 使用初始化列表底层效率比函数体赋值更高。
#include <iostream>
using namespace std;
class Base
{
    public:
        Base()
        {
            cout << "无参构造" << endl;
        }
        Base(int x)
        {
            cout << "有参构造" << endl;
        }
};
class Sub
{
    private:
        Base m_b;
    public:
        Sub()
        {
          m_b = Base(5);
        }
};
int main()
{
    Sub a;
    return 0;
}

在上面代码中,类Sub带有一个Base类对象m_b成员,编译器会优先执行类中对象成员的构造函数,最后才执行类自身的构造函数,在声明对象m_b时调用了无参构造,接着执行Sub自身的构造函数,又调用了一次Base的构造函数(有参),如果采用初始化列表,则只会调用一次有参构造。

2.类中带有const成员变量、引用成员变量、自定义类型成员变量(类无默认构造函数)时必须使用初始化列表。初始化const和引用变量的唯一机会就是通过初始化列表。

在上面代码中,我们知道了如果采用函数体赋值的方式,将会先调用一次无参构造,再调用一次有参构造,如果Base类没有默认构造函数,并且没有采用初始化列表方式将会发生错误。


new&delete

从堆区开辟空间,相当于C中的malloc和free函数,new会调用malloc函数申请空间,再调用构造函数进行初始化。

new&delte和malloc&free的区别有以下几点:

  1. malloc仅分配内存不会调用构造函数,free仅回收内存不会调用析构函数,而new&delete会。
  2. new返回的是某种数据类型的指针,malloc返回的是void类型指针,需要强制类型转换。
  3. new申请空间无须指定内存空间大小,由编译器来进行计算,而malloc需要显式声明所需空间大小。
  4. new&delete是操作符,操作符在编译阶段处理,不需要任何头文件,由编译器实现,而malloc&free是库函数,必须要有具体实现。

申请基本类型:

int *p = new int;//等于 int *p = (int*)malloc(sizeof(int));
delete p;// 等于 free(p);

申请基本类型数组:

int *arr = new int[5];
int *arr = new int[5]{1,2,3,4,5};//申请并初始化
delete []arr;//如果new时使用带有[],delete时就必须带有[]

申请类对象:

Person *p = new Person;//必须有默认构造函数
delete p;

申请类数组:

Person *p = new Person[5]{Person("lihua",18),Person("lucy",20)};//前两个成员调用有参构造,其余调用无参构造

delete和delete[]

对于基本数据类型,如int *a = new int[10].使用delete 和delete []都是正确的,但是对于自定义数据类型,不仅需要释放空间还需要调用析构函数完成清理工作,如果使用delete,仅会调用一次析构函数,而delete[]则会逐个调用。


静态成员

类的成员,成员变量和成员方法可以加static修饰使之成为静态成员,不管类实例化了多少个对象,静态成员只有一份,它被这个类的所有对象共享。静态成员属于类而不是属于对象,分配空间在编译阶段完成,对象还没有创建时,就已经分配空间,为对象分配空间时,不包括静态成员变量的大小。静态成员变量必须在类中声明,类外定义,它可以通过类名称访问,而普通成员变量不可以。

jcRNpn.jpg

#include <iostream>
using namespace std;
class Test()
{
  private:
  	static int m_data;
  public:
  	void show()
    {
      cout << m_data << endl;
    }
}
int Test::m_data = 10;//类中声明类外定义
int main()
{
  Test obA;
  Test obB;
  obA.show();//10
  obB.show();//10
  return 0;
}

我们知道私有成员变量可以通过公共接口成员方法进行访问,但普通成员方法依赖于对象调用,如果类没有实例化对象,就无法使用静态成员变量吗?实际上,静态函数的存在就是为了使用静态成员,它和静态成员变量一样都属于类,可以通过类名称访问,但不能访问非静态成员变量,而普通函数则都可以访问。静态成员变量可以被const修饰,而静态成员函数则不可以,因为this指针是具体对象的地址,所以static成员函数没有this指针,而const修饰函数实际是修饰this指针,自然也就没有必要使用const了。

和普通成员函数一样,静态成员函数可以在类外定义,不能重复static关键字,该关键字只能出现在类中的声明语句。

class Test
{
  private:
  	static int m_data;
  public:
  	static void showStatic();
}
void Test::showStatic(){ cout << m_data << endl; }

前面我们知道了不完全类型的概念,由于静态成员不属于类对象,所以静态成员同指针&引用一样为不完全类型。

class Test
{
  public:
  ///
  private:
  	Test *p;//✅ 指针可以为不完全类型
  	Test pp;//❌ 数据成员不可以为不完全类型
  	static Test ppp;//✅ 静态成员可以为不完全类型
}

单例模式

单例模式(Singleton Pattern)是设计模式中最简单的模式之一。简单来说,一个类有且仅有一个实例,那么就是单例模式。定义一个静态对象指针成员变量,保存唯一实例的地址,将构造函数都私有化,并且提供方法返回这个唯一的实例。

class Singleton
{
  private:
  	Sigleton(){}//构造函数私有化
  	Sigleton(const Sigleton &ob){}
  	static Singleton* instance;//保存唯一实例地址
  public:
  	static Singleton* getInstance(){ return instance; }		  
};
Sigleton* Singleton::instance = new Singleton;
int main()
{
  //通过类名称调用返回方法
  Singleton *p = Singleton::getInstance();
  return 0;
}

this指针

类的成员变量和成员函数是分开存储的,每个对象拥有独立的数据成员,但共享同一个方法,那么方法是如何做到操作的是哪一个对象的成员变量呢?当一个对象调用方法时,会在方法中产生一个this指针,this指针隐含在对象成员函数内,指向调用方法的对象,成员函数通过this指针即可知道操作哪一个对象的数据,任何对类成员的直接访问都被看作是this的隐式引用。它不占用类的大小,由编译器实现。静态成员函数没有this指针,所以不能操作非静态成员变量,也不能加const修饰。

const修饰成员函数后的this指针:

Test::fun(const Test* const this){ // }

const与指针:

指针p是一个指向整形常量的指针,它指向的值不能改变,也就是说我们无法通过指针p去修改变量a的值。

int a = 5;
const int *p = &a;
*p = 10;//❌

指针p是一个指向整形的常量指针,它不能指向别的变量,但是可以通过指针p修改变量a的值。

int a = 5;
int b = 10;
int * const p = &a;
p = &b;//❌
*p = 10;//✅

指针p是一个指向整形常量的常量指针,它不能指向别的变量也不能修改变量的值。

int a = 5;
int b = 10;
const int * const p = &a;
*p = 10;//❌
p = &b;//❌

this指针的应用:

当成员变量和形参重名时,可以用this指针区分

class Test
{
	private:
  	int num;
  public:
  	void setNum(int num)
    {
      this -> num = num; 
    }
  
};

在类的非静态成员函数返回对象本身

class Test
{
  public:
  	Test& Print(char *str)
    {
      cout << str;
      return *this; // *this 等价 ob
    }
}
int main()
{
  Test ob;
  ob.Print("h").Print("e").Print("l").Print("l").Print("o");
}

运算符重载

运算符重载就是对已有的运算符重新定义,赋予运算符新功能,它和其它普通函数一样也是一个函数,当编译器遇到适当的模式时,就会调用这个函数。函数中的参数个数取决于两个因素,运算符是一元运算符还是二元运算符,如果运算符被定义为非成员函数,参数的个数不变,但是如果为成员函数时,对于一元运算符来说将没有参数,二元运算符将有一个参数,这是因为类的对象充当左参数。

注意:运算符重载的目的是为了简化操作,不应该改变运算符的本质操作,比如你不应该将加号重载成相减的操作。

对于运算符重载定义为成员函数还是非成员函数,一般来说:

= [] () -> 必须重载成成员函数

递增、递减、解引用等改变对象状态的运算符

具有对称性的运算符 比如说 operator+为string类的成员函数,也就是+的左侧绑定为string类对象,所以调用的时候运算符+的左边必须是string类型。

语法 operator后面紧跟需要重载的运算符

例如:重载”<<”运算符

returntype operator << (type name) { /function body/ }
ostream& operator << (ostream &out, Person &ob) { /function body/ }

cout << ob 等价operator << (cout, ob)

可以通过成员函数完成函数重载,也就是说this充当其中一个参数

几乎所有的运算符都可以重载,但是不能重载没有意义的运算符,不能改变运算符的优先级、参数个数等

不能重载的运算符有:” . “ “ :: “ “ .*” “?:” sizeof

当重载自增自减运算符的时候需要区分前置和后置,编译器默认识别前置,如果需要实现后置,需要使用占位符int

#include <iostream>
using namespace std;
class Data {
private:
    int a;
    int b;
public:
    Data();
    Data operator ++();
    Data operator ++(int);
    friend ostream& operator << (ostream &os, Data &ob);
};
Data::Data():a(0),b(0){ }
Data Data::operator ++ () {//重载前置自增
    this -> a++;
    this -> b++;
    return *this;
}
Data Data::operator ++ (int) {//重载后置自增
    Data tmp = *this;
    this -> a++;
    this -> b++;
    return tmp;
}
ostream& operator << (ostream &os, Data &ob) {
    os << "a = " << ob.a << " b = " << ob.b << endl;
    return os;
}
int main() {
    Data x;
    ++x;
    cout << x;//a = 1 b = 1
    Data y = x++;
    cout << y;//a = 1 b = 1
    cout << x;//a = 2 b = 2
    return 0;
}

当类中含有指针成员必须重载 “=”运算符,因为当使用一个旧对象给新对象赋值,调用的是拷贝构造(旧对象=旧对象为赋值操作),默认的拷贝构造是浅拷贝,如果类中有指针成员,会导致两个

对象的指针成员指向同一块内存,当对象调用析构函数的时候,就会造成对同一块空间进行两次释放操作

#include <iostream>
using namespace std;
class test {
private:
  char *name;
public:
  test();
  test(const char* name);
  ~test();
};
test::test() : name(nullptr) { }
test::test(const char* name) {
  this -> name = new char[strlen(name) + 1];
  strcpy(this -> name, name);
}
test::~test() {
  if (this -> name) {
    delete []this -> name;
  }
}
int main() {
  test a("a");
  test b = a;
}

test b = a;这里是旧对象给新对象赋值,调用的是拷贝构造,由于我们没有实现拷贝构造,调用的系统拷贝构造是单纯的赋值,会导致a的name成员和b的name成员共享一块内存,当a和b调用析构函数的时候就会释放已经释放的空间,导致错误

//只需要实现深拷贝即可
test::test(const test &ob) {
  this -> name = new char[strlen(ob.name) + 1];
  strcpy(this -> name, ob.name);
}

当我们实现了深拷贝后,使用新对象=旧对象的操作就没有问题了,但如果是旧对象=旧对象就会产生错误,还需要重载 “ = “运算符

test a("a");
test b;
b = a;//❌ 旧对象给旧对象赋值
//重载 "=" 运算符
class test {
private:
  char *name;
public:
  void operator = (const test &ob);
  //...
};
void test::operator = (const test &ob) {
  this -> name = new char[strlen(ob.name) + 1];
  strcpy(this -> name, ob.name);
}
//...

为什么不要重载 &&和||运算符

因为我们无法实现&&和||运算符的短路功能,下面的代码中a和b是基础类型 ,(a && (a + b))当a为假时后面的a+b不会执行。而obja && (obja + objb)相当于obja.operator&& (obja.operator + (objb)),这里会先计算obja + objb的值。

#include <iostream>
using namespace std;
class test {
private:
    int i;
public:
    test(int num);
    test operator + (const test &ob);
    bool operator && (const test &ob);
};
test::test(int num) : i(num) { }

bool test::operator && (const test &ob) {
    return this -> i && ob.i;
}
test test::operator + (const test &ob) {
    cout << "+" << endl;
    test ret(0);
    ret.i = this -> i + ob.i;
    return ret;
}
int main() {
    int a = 0, b = 1;
    if (a && (a + b)) {
        cout << "true" << endl;
    } else {
        cout << "false" << endl;
    }
    test obja(0), objb(1);
    if (obja && (obja + objb)) {
        cout << "true" << endl;
    } else {
        cout << "false" << endl;
    }
    return 0;
}

lambda表达式

lambda表达式是C++11的新特性,lambda表达式可以理解为内嵌的匿名函数,和普通函数类似,一个lambda具有一个返回类型、一个参数列表、一个函数体,并且lambda表达式可以定义在函数内部。

lambda表达式的形式如下:

[capture list] (parameter list) -> return type { function body }

capture list(捕获列表),如果lambda表达式要使用所在函数内部的变量,就必须显式或隐式地在捕获列表声明,捕获列表可以为空。注意参数列表和返回类型可以省略,但捕获列表和函数体必须存在(即使为空)。

auto f = [] { return 42; };

与普通函数不同,lambda的返回类型必须采用尾值返回,且如果返回类型被忽略了,存在两种情况:1.lambda的函数体内部只有一条return语句 ,则会根据return的表达式来推导出返回类型。2.lambda的函数体内部不止一条return语句,则返回类型为void。

lambda的捕获列表

[] 空捕获列表。lambda不能使用所在函数中的变量
[names] 捕获列表。默认为拷贝捕获
[&] 隐式捕获。采用引用捕获lambda所在函数的所有变量
[=] 隐式捕获。采用拷贝捕获lambda所在函数的所有变量
[&, names] 混合捕获。除了names以外的都采用引用捕获
[=, names] 混合捕获。除了names以外的都采用拷贝捕获

注意采用混合捕获的时候,names中的捕获方式都不能和前面的捕获方式相同

[&, &a, &b, &c]❌
[&, =a, =b, =c]✅

如果我们想使用拷贝捕获,又想修改变量的值(默认为不能修改),可以使用mutable关键字,注意这里的修改指的是拷贝捕获的值,而不会影响到外部的那个变量(引用捕获才能影响),所以通过mutable修改的值出了lambda表达式后就无效了。注意:被mutable修饰的lambda表达式就算没有参数也要写明参数列表。

lambda是函数对象

当我们定义了一个lambda表达式后,编译器根据表达式产生一个匿名类的匿名对象,这个类里带有一个public类型的重载函数调用运算符的函数。

for_each(vec.cbegin(), vec.cend(), [](int val) { cout << val << " ";});

for_each第三个参数为lambda表达式,其类似一个类的匿名对象,默认情况下lambda表达式不能改变它捕获的变量,所以重载函数调用运算符的函数是const的

,可以将lambda声明为mutable

class {
  public:
  	void operator ()(int val) const { cout << val << " "; }
};

当lambda表达式采用引用捕获变量的时候,必须确保lambda执行时所引用的对象存在,因此lambda产生的类中无需将捕获变量存储为数据成员。

当lambda表达式采用拷贝捕获变量的时候,lambda产生的类必须为每个值都建立对应的数据成员,同时创建构造函数,用捕获的变量来初始化数据成员。

lambda表达式产生的类不含默认构造函数,赋值运算符(无法赋值但可以使用拷贝构造函数)及默认析构函数。

没有捕获变量的lambda表达式可以直接转换为函数指针,而捕获变量的lambda表达式则不能转换为函数指针。


动态内存&智能指针

C++中使用new&delete/malloc&free来管理动态内存,但动态内存的使用容易出现问题:1.内存泄漏 2.使用空悬指针 3.对已释放的内存进行释放

void test() {
  int *p = new int(5);
}
int main() {
  test();
}

上面这段代码造成了内存泄漏,这是因为忘记使用delete操作导致的,操作指针p是我们使用这块空间的唯一方式,指针p为局部变量,当test函数调用结束时将被销毁,而空间是动态分配的,如果没有显示销毁它,它将会直到程序结束才被释放。

为了解决这些问题,C++引入了两种智能指针来管理动态内存空间——shared_ptr&unique_ptr以及伴随类weak_ptr。智能指针也是模版,当创建一个智能指针的时候必须指明类型,类似普通指针,通过解引用可以得到指向的对象。有了智能指针,我们不需要显示释放资源。

shared_ptr

shared_ptr允许多个指针指向同一个对象。

shared_ptr<string> p1;//指向stirng,默认为空
shared_ptr<int> p2;//指向int,默认为空

shared_ptr&unique_ptr都支持的操作

shared_ptr sp 空的shared指针,指向类型T
unique_ptr up 空的unique指针,指向类型T
p p指向对象则为空,否则为NULL
*p 解引用p,获得对象
p -> mem *等价(p).mem
p.get() 返回一个内置指针
swap(p, q) 交换指针
p.swap(q) 交换指针

shared_ptr独有的操作

make_shared(args) 返回一个shared_ptr,使用args初始化此对象
shared_ptr p(q) p为q的拷贝,增加q中的计数器
p = q 此操作递减q的引用计数,递增p的引用计数
p.unique() 若p.use_count()为1则为true否则为false
p.use_count() 返回与p共享对象的智能指针数量
shared_ptr p(q) p管理内置指针q所指的对象,p销毁时空间也将被释放
shared_ptr p(u) p从unique_ptr接管对象的所有权,将u置NULL
p.reset() 如果shared_ptr的use_count = 1,reset将会释放此对象,并且如果传递了可选参数内置指针q,会让p指向q,否则将p置NULL,并且可以传递删除器

shared_ptr会自动销毁对象和释放内存

每个shared_ptr都会带有一个被称为计数引用的关联的计数器,它记录着有多少个shared_ptr 指向相同的对象,当我们拷贝shared_ptr时,计数器就会增加,当一个shared_ptr销毁时,计数器就会递减,当计数器为0时,表明指向一个对象的最后一个shared_ptr已被释放,shared_ptr类会自动销毁对象和释放内存。

shared_ptr<int> p1(make_shared<int>(5)); //p1.used_count() = 1
shared_ptr<int> p2(p1);
//p1.used_count() = 2 p2.used_count() = 2

除了使用make_shared函数来初始化智能指针,还能使用new返回的指针来初始化指针智能,接受指针参数的智能指针构造函数是explicit的,必须使用直接初始化形式。

shared_ptr<int> p1 = new int(1024);//❌
shared_ptr<int> p2(new int(1024));//✅

不要混合使用普通指针和智能指针

当将一个shared_ptr绑定到一个普通指针时,这块空间将由智能指针管理,从普通指针的角度来说,我们无法得知对象何时会被释放,来看看下面这个例子

void func(shared_ptr<int> ptr) { // }
int main() {
  int *p = new int(1024);
  func(shared_ptr<int> (p));
  auto x = *p;//❌
}

上面代码中,使用内置指针p构造了一个shared_ptr,这个shared_ptr是一个局部对象,生存周期仅在func函数运行期间,当func函数结束时,shared_ptr将被销毁,因为ptr是指向这块空间的唯一一个智能指针,当它被释放时,空间也将被释放,p成为了空悬指针,如果试图访问p将引发错误。

auto sp = make_shared<int>();
auto p = sp.get();
delete p;

用内置指针管理智能指针是危险的,sp指向的空间已被释放,但sp.use_count()仍然为1,sp成为空悬指针。

get函数

get函数返回一个内置类型指针,但要注意:

1.使用get返回的指针不能delete空间,因为这块空间时归智能指针管理的

2.不能使用get返回的指针来为智能指针初始化或赋值

shared_ptr<int> p(new int(5));
int *q = p.get();
{ //新语句块
  shared_ptr<int> tmp(q);
}
// p成为空悬指针

这是错误的,原因是这样不会形成正确的动态对象共享,虽然p和tmp指向同一块空间,但它们不知道对方的存在,互为独立的,当tmp被销毁时,由于use_count = 0,所以空间将被释放,而对于p来说,它认为这块空间仍未释放,实际为空悬指针。

智能指针管理非动态内存

默认情况下,智能指针必须管理动态内存,因为智能指针默认使用delete操作,但也可以将其绑定到一个指向其它类型的指针上,这样必须提供操作(删除器)来代替delete。

创建一个shared_ptr时,可以传递一个指向删除器的函数的参数

int x = 5;
shared_ptr<int> sp(&x, [](int *p) { *p = 0;});

当sp销毁时,x = 0,因为我们实现了操作(将空间内容置为0)来代替默认的delete操作。

unique_ptr管理删除器的方式与shared_ptr不同,我们必须在尖括号中unique_ptr指明类型之后,指明删除器类型(类似关联容器重载比较方式)

void helper(int *p) {
  *p = 0;
}
int main() {
  int x = 5;
  using F = void (int*);
  unique_ptr<int, F*> up(&x, helper);
}

智能指针的好处还在于异常处理

void func() {
  int *p = new int(5);
  /*
    使用p
   */
  delete p;
}

这段代码看上去没有什么问题,我们使用了p,在函数结束前,手动释放了p,但是如果delete之前出现了异常,p将不会被释放。

unique_ptr

unique_ptr独享它所指向的对象,当unique_ptr被销毁时,它所指向的对象也将被释放,不可以拷贝或赋值unique_ptr,其拷贝构造和赋值函数声明为了delete。

unique_ptr的初始化方式类似shared_ptr,注意make_unique为C++14的新特性。

unique_ptr一些独有操作

unique u 空unique_ptr
unique<T,D> u(d) 空unique_ptr,用类型D的对象d来代替默认delete操作
u = nullptr 置空
u.release() u放弃对指针的控制权,返回指针并置空
u.reset() 释放u所指的对象,可提供可选参数传递一个内置指针q,令其指向q

智能指针管理动态数组

在声明unique_ptr的时候,在对象类型后面加上一对方括号,表示类型为数组。

unique<int[]> up(new int[10]);
up.release();//自动调用delete[]
//
int *q = new int[10];
unique<int[]> up2(q);

unique_ptr管理数组时,不能使用点和箭头成员运算符,可以使用下标运算符

for (size_t i = 0 ; i < 10 ; ++i) {
  up[i] = i;
}

也能使用shared_ptr管理动态数组,但必须提供删除器,因为默认情况下,shared_ptr使用delete删除对象。并且不支持下标运算符,必须使用get返回内置指针,再通过内置指针来访问元素。

shared_ptr<int> sp(new int[10], [](int *p){ delete []p;});
for (size_t i = 0 ; i != 10 ; ++i) {
  *(sp.get() + i) = i;
}
sp.reset();

三五法则


继承

继承是面对对象程序设计中的一种机制,可以利用已有的数据类型来定义新的数据类型,实现代码复用,可以在原有类的基础上进行扩展,新的类不仅有继承过来的成员,还拥有属于自己的成员。一个B类继承于A类,或从A类派生出B类,则A类为基类(父类),B类为子类(派生类),从基类继承过来的表现其共性,而自身的成员体现了个性。

派生类定义格式:
class 派生类名 : 继承方式 基类名 { }

继承个数分类:单继承和多继承

派生类继承基类,派生类拥有基类中的全部成员变量和成员方法(除了构造和析构函数),但派生类具体能直接访问基类的哪些成员取决于访问权限。

派生类继承基类一共有三种方式:public、private、protected三种方式 ,如下图:

三种派生

看起来很复杂,实际上有一定的规律可循

1.不管是什么继承,父类中的private成员在子类都不可见(不管是在子类的外部还是内部)

2.公有派生保持不变,私有派生变为私有,保护派生变为保护

protected

protected位于public和private之间,当派生类继承基类后,继承的public成员类内类外都可访问,private成员都不可见,而protected对于派生类类内是可访问的(对对象不可访问)。

访问说明符是对上层的说明

派生访问说明符是对直接基类的一种声明,不会作用于间接基类。下面代码中,对于C来说其直接基类B private继承于间接基类A,此时不管C使用何种派生方式,对与B和C来说,基类A中的数据都是private的。

#include <iostream>
class A {
public:
  int pub_m;
protected:
  int pro_m;
private:
  int pri_m;
};

class B : private A {
  
};

class C : public B {
  
};
int main() {
  C c;
  std::cout << c.pro_m << std::endl;//错误
  return 0;
}
继承和友元

友元关系不能传递,A和B为父子关系,B和C为父子关系,不能说A和C为父子关系,同样在继承中,友元是不被继承的,每个类负责自己的访问权限。

需要注意的是,如下图,other是Base的友元,other可以访问Base对象的成员,但是也包括其派生类的派生部分,other可以访问Derived的派生部分,但是不能访问Derived的自定义部分。

下图展示了友元不具有继承性,Derived的基类other和Base是友元,但不代表Derived和Base是友元。

同样的,每个类负责控制自己的访问权限,派生类是没有权限能够控制其基类的友元权限的。

改变成员的可访问性

使用using声明可以改变派生类派生部分的可访问性(基类的非私有成员),下面代码中,由于派生类private继承,成员a、c在派生类中默认为private,但可以通过using将可访问性进行修改。

#include <iostream>
class Base {
public:
	int a;
private:
  int b;
protected:
  int c;
};

class Derived : private {
public:
  using Base::a;
  using Base::c;
  using Base::b;//错误,b在基类中为private,是不可见的,无法使用using
};
int main() {
  Derived d;
  std::cout << d.a << " " << d.c << std::endl;
  //a和c的可访问性被修改为public,因此可以通过对象访问
  return 0;
}
类和class的区别

从技术层面来说,struct和class的区别只有两点:

1.class的默认访问权限为private,struct则为public

2.class的默认继承为private,struct则为public

除此之外,别无二致。

派生类中的构造

派生类不能直接初始化基类部分的成员,尽管可以在派生类中的构造函数体对基类部分进行初始化,但应该遵循基类的接口,应该通过基类的构造函数来初始化。

派生类在创建对象时首先会调用基类的构造函数,再基类调用完构造函数后,才会调用派生类的构造函数,而析构的顺序则相反,会先调用派生类的构造函数。

基类和派生类的构造顺序

派生类如果带有对象成员,那么其构造顺序为1.基类构造 2.对象成员构造 3.自身构造,析构顺序为:1.自身析构 2.对象成员析构 3.基类析构

基类、对象成员、派生类的构造顺序

默认情况下,派生类调用基类的无参构造,如果基类没有提供默认构造函数,派生类又没有显示调用基类的构造函数,就会出错,派生类必须使用初始化列表来显示构造基类。

派生类和基类的成员可以重名,但是派生类会采取就近原则,选择本作用域的成员,如果想要操作或访问基类的成员,就必须加上基类的作用域,派生类可以通过基类公共方法来访问基类的私有成员。当派生类实现了基类的同名成员函数,将屏蔽基类的同名成员函数(所有版本),必须加上基类的作用域才能访问。

基类和派生类的类型转换
  • 派生类向基类的类型转换只对指针或引用有效

  • 不存在从基类向派生类的隐式转换

  • 派生类向基类的转换不一定是有效的

基类指针、引用指向派生类对象是合法的,例如

Base *p = new Derived;

基类指针、引用可以指向派生类对象,意味着该指针、引用具体的真实类型由该引用或指针所绑定对象的具体类型决定(是基类or派生类),我们必须理解静态类型和动态类型

Base *p;//p的静态类型为Base
p = new Base;//p的动态类型为Base
p = new Derived;//p的动态类型为Derived

但不存在基类向派生类的隐式转换,即使该基类绑定在一个派生类对象上,编译器的检查工作根据指针或引用的静态类型来判断转换是否合法,如果我们确保这种转换是安全的,可以使用关键字static_cast来完成强制转换

Derived *p = new Base;//不合法
Base *p = new Derived;
Derived *q = p;//不合法
q = static_cast<Derived*>(p);//强制转换

不存在非指针或引用类型对象之间的转换,但我们可以用派生类对象为基类初始化或赋值,所调用的是基类的构造或赋值函数,当派生类为基类赋值时,其派生部分可以不使用,只使用基类部分来赋值,反之,使用基类为派生类赋值时,没有可以使用的数据为派生类的自定义部分,所以是不行的。

final & override关键字

如果派生类重写了基类的虚函数,可以在派生类中使用virtual关键字来表明该函数为虚函数,但这不是必须的,我们必须清楚当函数被声明为virtual后,则在所有派生类中都是virtual的。

派生类中重写的虚函数必须和基类中的保持返回类型、参数类型、参数个数一致,

但存在一个例外,当虚函数的返回类型为自身的指针或引用时,派生类重写的版本可以为派生类本身而不是基类,例如基类中版本为virtual Base* func(),派生类版本可以为virtual Derived* func()。当派生类中的虚函数与基类中的形参列表不同,从语法上来讲是没有问题的,该函数与基类中的虚函数互为独立函数,实际上并没有对虚函数进行重写,为了避免这种错误,可以使用override关键字,通常放在参数列表右边,使用override标记某个函数,表明需要重写基类中的虚函数,如果没有对应需要重写的虚函数,则会报错。

class Base {
public:
	virtual void fun1(int);
  virtual void fun2();
  void fun3();
};

class Derived : public Base {
public:
	void fun1(int); override;//正确,有对应的虚函数需要重写
  void fun2(double); override;//错误,没有形参为double的虚函数
  void fun3(); override;//错误,fun3不为虚函数
};

final关键字可以作用于类或函数

当final作用于类时,表明该类不可作为基类被继承。

class Base final {
 	//...
};
class Derived : public Base { //... }//错误

当final作用于函数时,表明该函数在所有派生类中不可再被重写。

class A {
public:
  virtual void fun(int);
};

class B : public A {
public:
  virtual void fun(int) final;//正确,重写虚函数fun
};

class C : public B {
public:
  virtual void fun(int);//错误,fun被声明为final了
};
虚函数

如果我们希望派生类重写基类的函数,可以将函数标记为virtual,使其成为虚函数。在不涉及多态的情况下,函数调用的具体版本由对象的静态类型决定,当且仅当通过指针或引用调用虚函数时才会发生动态绑定,此时,函数版本由动态类型决定。不同与普通函数,普通函数只要不被使用,是可以只声明而不进行定义的,但是虚函数则不行。

图为一个类的布局(非物理模型)

当类中存在的虚函数个数大于等于1时,生成虚函数指针(virtual table pointer),其指向虚函数表(virtual table)

存在虚函数的类布局

当存在继承时并且派生类没有重写基类中的虚函数,派生类中的虚函数表保存的还是基类版本的函数

存在继承并未重写虚函数

当存在继承关系时并且派生类重写了基类的虚函数,派生类中虚函数保存的函数指针将被更新为派生类自身版本的

重写虚函数后

抽象基类

当我们想创建一个类,这个类只包含未实现的方法,提供接口,相当于模版作为基类被继承,派生类必须强制去实现未实现的方法,并且不能去实例化这个类,可以将函数定义为纯虚函数,含有纯虚函数的类为抽象基类

virtual void func() = 0;

模板&泛型

模版和重载

函数模板可以和类似普通函数重载一样,同名字相同但参数列表不同的模板或非模板函数构成重载。

对于重载函数模板的匹配规则,遵循如下:

模板实参推断成功的函数模板实例,都为可调用的函数,在这些实例中,按照类型转换来排序(const转换、数组和函数指针转换)。

在这些实例中,如果一个函数的优先级都比其余的高,选择此函数。但是,如果出现多个优先级相同的可行函数,那么:如果为多个模板,仅有一个为非模板,选择非模板,如果都为模板,选择特例化的版本,否则,该调用带有歧义。

//版本1
template <typename T>
void func(const T &t) {}

//版本2
template <typename T>
void func(T *p) {}

string s("string");
func(s);

对于上面代码的调用,会选择第一个版本,可调用版本只有一个,因为第二个版本的参数为指针参数,我们不可以将非指针参数转换为指针参数。如果将调用形式从func( s )改为 func( &s ) ,则会产生两个可调用版本,但是按照匹配规则,第二个版本是精确匹配的,而第一个版本需要进行const转换,所以此调用选择第二个版本。

const string *p = &s;
func(p);

接着进行如上的调用,p为const string*,因此版本一和版本二都是可行的,但是会选择版本二进行调用,因为版本二为指针参数,不同于版本一的const T&可以接受指针或非指针参数,版本二是更有特例化的。

下面的例子为非模板和模板重载,版本三为

//版本3
void func(const string &s) {}

对于函数调用 func( s ),其可行函数为版本一和版本三,但是实际会调用版本三,因为对于模板和非模板,编译器会选择非模板的进行调用。

对于函数调用func(“string”),参数类型为const char*,以上的三个版本都为可选的,但版本二更为特例(版本一更为广泛,版本三需要进行类型转换)。

模版特例化

模版的存在是为了泛化,但可能存在泛化的版本不能满足需求,因此需要将模版进行特例化。以最初的compare函数为例子,该函数可以比较任意类型:

//版本一
template <typename T>
int compare(const T &a, const T &b) {
  if (a < b) return -1;
  if (b < a) return 1;
  return 0;
}

如果处理字符串,通常我们希望比较的字符串而非指针值,于是对compare进行重载:

//版本二
template <size_t N, size_t M>
int compare(const char(&a)[N], const char(&b)[M]) {
  return strcmp(a, b);
}

当调用的实参为字符串字面量或字符数组时(a、b长度不同),因为其长度也是类型的一部分,版本一要求两个参数的类型都为T(长度一致),因此会调用版本二。需要注意的是当a、b长度相同时,调用会产生歧义,版本一和版本二优先级相同。

但当实参为字符指针时,我们不能将指针转换为数组引用,因此会调用版本一,为了处理字符指针,可以定义模板特例化版本。

template后接一个空<>表示特例化,特例化表明我们会去指定类型,为原模板的所有模板参数都提供实参:

//版本三 特例化版本
template<>
int compare(const char* const &a, const char* const &b) {
  return strcmp(a, b);
}

文章作者: tang ran
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 tang ran !
  目录