本文讲义大部分来自黑马程序员
仅供学习使用
C++基础知识
1 函数的分文件编写
- 创建.h后缀名的头文件
- 创建.cpp后缀名的源文件
- 在头文件中写函数的声明
- 在源文件中写函数的定义
newadd.h文件
#include<iostream>
void newadd(int a, int b);
newadd.cpp文件
#include<iostream>
void newadd(int a, int b) {
std::cout << a + b << std::endl;
}
test.cpp文件
#include<iostream>
#include"newadd.h"
int main() {
int a, b;
std::cin >> a >> b;
newadd(a, b);
}
2 指针
指针所占的内存空间
32位操作系统下,无论什么数据类型,指针都占用4个字节
64位操作系统下,无论什么数据类型,指针都占用8个字节
#include<iostream>
int main() {
std::cout << sizeof(int *) << " ";
std::cout << sizeof(char *) << " ";
std::cout << sizeof(double *) << " ";
std::cout << sizeof(float *) << std::endl;
}
输出8 8 8 8
2.1 空指针
空指针:指针变量指向内存中编号为0的空间(内存编号0~255为系统占用内存,不允许用户访问)
用途:初始化指针变量
注意:空指针指向的内存是不可以访问的
#include<iostream>
int main() {
//空指针用于指针变量进行初始化
int * p = NULL;
//空指针是不可以进行访问的
std::cout << *p << std::endl;
return 0;
}
2.2 野指针
野指针:指针变量指向非法的内存空间
#include<iostream>
int main() {
//指针变量p指向内存地址编号为0x1100的空间
int * p = (int *)0x1100;
//访问野指针会报错
std::cout << *p << std::endl;
return 0;
}
2.3 const修饰指针
1.const修饰指针 —常量指针
const int * p = &a
指针指向的值不可以改,指针的指向可以改;
*p=20; 错误 p=&b;正确
2.const修饰常量 —指针常量
int * const p2 = &a;
指针指向的值可以改,指针的指向不可以改
*p2=20; 正确 p2=&b;错误
3.const即修饰指针,又修饰常量
指针指向的值不可以改,指针的指向不可以改
const int * const p3 = &a;
*p3=20; 错误 p3=&b;错误
2.4 指针和数组
#include<iostream>
int main() {
int arr[5] = { 1,2,3,4,5 };
int * p = arr;
std::cout << *p << std::endl;
p++;
std::cout << *p << std::endl;
return 0;
}
2.5 指针和函数
值传递
地址传递
3 结构体
3.1 结构体指针:
#include<iostream>
#include<string>
struct Student {
std::string name;
int age;
int score;
};
int main() {
Student s={"小王",20,100};
Student *p=&s;
//通过指针访问结构体变量中的属性,需要利用 ->
std::cout << p->name << " " << p->age << " " << p->score << std::endl;
return 0;
}
3.2 结构体中const使用场景
当结构体使用地址传递时会减少内存空间,而且不会复制新的副本出来,一个指针只占4个字节(64位编译模式下占8个字节),而如果使用值传递时,会占用sizeof(结构体),而加入const之后,就会使这个结构体变成in read-only object的
#include<iostream>
#include<string>
struct Student {
std::string name;
int age;
int score;
};
//将函数中的形参改为指针,可以减少内存空间,而且不会复制新的副本出来
void printStudent(const Student *s) {
//s->age=66; //加入const之后,一旦有修改的操作就会报错,可以防止我们的误操作
std::cout << s->name << " " << s->age << " " << s->score << std::endl;
}
int main() {
Student s={"小王",20,100};
Student *p=&s;
std::cout << sizeof(p) << std::endl;
printStudent(&s);
return 0;
}
4 内存
参考:1、BiliBili黑马程序员
2、博客
4.1 内存分区模型
C++程序在执行时,将内存大方向划分为4个区域
- 代码区:存放函数体的二进制代码由操作系统进行管理的
- 全局区:存放全局变量和静态变量以及常量。程序结束后由系统释放。全局区分为已初始化全局区(data)和未初始化全局区(bss)。
- 栈区:由编译器自动分配与释放,存放为运行时函数分配的局部变量、函数参数、返回数据、返回地址等。其操作类似于数据结构中的栈。
- 堆区:一般由程序员自动分配,如果程序员没有释放,程序结束时可能有OS回收。其分配类似于链表。
内存四区意义:
不同区域存放的数据,赋予不同的生命周期,给我们更大的灵活编程
4.1.1 程序运行前
在程序编译后,生成了exe可执行程序,未执行该程序前分为两个区域
代码区: 存放 CPU 执行的机器指令
代码区是共享的,共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可
代码区是只读的,使其只读的原因是防止程序意外地修改了它的指令
全局区:
全局变量和静态变量存放在此.
全局区还包含了常量区, 字符串常量和其他常量也存放在此.
该区域的数据在程序结束后由操作系统释放。
#include<iostream>
//全局变量
int g_a=10;
int g_b=20;
//全局常量
const int c_g_a=10;
const int c_g_b=20;
int main() {
/*
全局区:全局变量、静态变量 static关键字、常量(分为1、字符串常量2、const修饰的全局变量)
非全局区:局部变量,const修饰的局部变量(局部常量)
*/
//局部变量
int a=10;
int b=20;
std::cout << "局部变量a的地址: " << &a << std::endl;
std::cout << "局部变量b的地址: " << &b << std::endl;
//const修饰的局部变量(局部常量)
const int c_l_a=10;
const int c_l_b=20;
std::cout << "局部常量c_l_a的地址: " << &c_l_a << std::endl;
std::cout << "局部常量c_l_b的地址: " << &c_l_b << std::endl;
//全局变量
std::cout << "全局变量g_a的地址: " << &g_a << std::endl;
std::cout << "全局变量g_b的地址: " << &g_b << std::endl;
//静态变量
static int s_a=10;
static int s_b=20;
std::cout << "静态变量s_a的地址: " << &s_a << std::endl;
std::cout << "静态变量s_b的地址: " << &s_b << std::endl;
//字符串常量
std::cout << "字符串常量的地址: " << &("Hello") << std::endl;
std::cout << "字符串常量的地址: " << &("World") << std::endl;
//const修饰的全局变量(全局常量)
std::cout << "全局常量c_g_a的地址: " << &c_g_a << std::endl;
std::cout << "全局常量c_g_b的地址: " << &c_g_b << std::endl;
return 0;
}
局部变量a的地址: 0x61fe1c
局部变量b的地址: 0x61fe18
局部常量c_l_a的地址: 0x61fe14
局部常量c_l_b的地址: 0x61fe10
全局变量g_a的地址: 0x403010
全局变量g_b的地址: 0x403014
静态变量s_a的地址: 0x403018
静态变量s_b的地址: 0x40301c
字符串常量的地址: 0x4040bf
字符串常量的地址: 0x4040c5
全局常量c_g_a的地址: 0x404004
全局常量c_g_b的地址: 0x404008
总结:
- C++中在程序运行前分为全局区和代码区
- 代码区特点是共享和只读
- 全局区中存放全局变量、静态变量、常量
- 常量区中存放 const修饰的全局常量 和 字符串常量
4.1.2 程序运行后
栈区:
由编译器自动分配释放, 存放函数的参数值,局部变量等
注意事项:不要返回局部变量的地址,栈区开辟的数据由编译器自动释放
#include<iostream>
//如果有形参,形参也存在栈区
int* func() {
int a=10; //局部变量 存放在栈区,栈区数据在函数执行完后自动释放
return &a;
}
int main(){
int *p =func();
std::cout << *p << std::endl; //第一次可以打印正确的数字,是因为编译器做了保留
std::cout << *p << std::endl; //第二次数据就不再保留了
return 0;
}
堆区:
由程序员分配释放,若程序员不释放,程序结束时由操作系统回收
在C++中主要利用new在堆区开辟内存
#include<iostream>
int* func() {
//利用new关键字 可以将数据开辟到堆区
//指针 本质也是局部变量,放在栈上,指针保存的数据放在堆区
int * a = new int(10);
return a;
}
int main() {
//在堆区开辟数据
int * p = func();
std::cout << *p << std::endl;
std::cout << *p << std::endl;
return 0;
}
4.2 new操作符
C++中利用new操作符在堆区开辟数据
堆区开辟的数据,由程序员手动开辟,手动释放,释放利用操作符 delete
语法:new 数据类型
,delete 地址
利用new创建的数据,会返回该数据对应的类型的指针
#include<iostream>
//new的基本语法
int * func() {
//在堆区创建整型数据
//new返回是该数据类型的指针
int * p = new int(10);
return p;
}
void test01() {
int * p = func();
std::cout << *p << std::endl;
std::cout << *p << std::endl;
//堆区的数据 由程序员管理开辟,程序员管理释放
//如果想释放堆区的数据,利用关键字delete
delete p;
//std::cout << *p << std::endl; //内存已经释放,再次访问就是非法操作,会做错
}
//2、在堆区利用new开辟数组
void test02() {
//创建10整型数据的数组,在堆区
int * arr = new int[10];//10代表数组有10个元素
for(int i = 0;i < 10 ; i++) {
arr[i]=i;
}
for(int i=0;i<10;i++) {
std::cout << arr[i] << std::endl;
}
//释放堆区的数组 释放数组的时候 要加[]
delete[] arr;
}
int main() {
test01();
test02();
return 0;
}
4.3 三种内存分配方式
- 从静态存储区分配
内存在程序编译的时候已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。
- 在栈上创建
在执行函数时,函数内局部变量的存储单元可以在栈上创建,函数执行结束时,这些内存单元会自动被释放。
栈内存分配运算内置于处理器的指令集,效率高,但是分配的内存容量有限。
- 从堆上分配
亦称为动态内存分配。
程序在运行的时候使用malloc或者new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。
动态内存的生命周期有程序员决定,使用非常灵活,但如果在堆上分配了空间,既有责任回收它,否则运行的程序会出现内存泄漏,频繁的分配和释放不同大小的堆空间将会产生内存碎片。
4.3.1 内存分配简易图
补充:
在 C 语言中,全局变量又分为初始化的和未初始化的(未被初始化的对象存储区可以通过 void* 来访问和操纵,程序结束后由系统自行释放),在 C++ 里面没有这个区分了,他们共同占用同一块内存区。
4.4 动态内存与智能指针
5 引用
5.1 基本使用
作用:给变量起别名
语法:数据类型 &别名 = 原名
#include<iostream>
int main() {
//引用基本语法:数据类型 &别名 = 原名
int a = 10;
int &b = a;
b = 20;
std::cout << a << std::endl;
return 0;
}
5.2 注意事项
- 引用必须初始化
- 引用在初始化后,不可改变
5.3 引用做函数参数传递
作用:函数传参时,可以利用引用的技术让形参修饰实参
优点:可以简化指针修改实参
#include<iostream>
void Swap(int &a,int &b) {
int temp=a;
a=b;
b=temp;
}
int main() {
int a=10;
int b=20;
Swap(a,b);//引用传递,形参会修饰实参
std::cout << a << std::endl;
std::cout << b << std::endl;
return 0;
}
输出:
20
10
5.4 引用做函数的返回值
作用:引用是可以作为函数的返回值存在的
注意:不要返回局部变量引用
用法:函数调用作为左值(等号左侧的值)
#include<iostream>
//不要返回局部变量的引用 局部变量存在栈中,函数调用结束后局部变量会被回收
int& test01() {
int a = 10;
return a;
}
//函数的调用可以作为左值
int& test02() {
static int a = 10; //静态变量存在堆中,只有整个程序结束后才会被回收
return a;
}
int main() {
int &ref = test01();
// std::cout << ref << std::endl; //第一次结果正确,编译器将结果做了保留
// std::cout << ref << std::endl; //第二次结果乱码。a的内存已经释放
int &ref2 = test02();
std::cout << ref2 << std::endl;
std::cout << ref2 << std::endl;
test02() = 1000;
std::cout << ref2 << std::endl;
std::cout << ref2 << std::endl;
return 0;
}
5.5 引用本质
本质:引用的本质在c++内部实现是一个指针常量
#include<iostream>
//发现是引用,转换为 int* const ref = &a;
void func(int& ref) {
ref = 100; // ref是引用,转换为*ref = 100
}
int main() {
int a = 10;
//自动转换为 int* const ref = &a; 指针常量是指针指向不可改,也说明为什么引用不可更改
int& ref = a;
ref = 20; //内部发现ref是引用,自动帮我们转换为: *ref = 20;
std::cout << "a:" << a << std::endl;
std::cout << "ref:" << ref << std::endl;
func(a);
return 0;
}
5.6 常量引用
作用: 常量引用主要用来修饰形参,防止误操作
在函数形参列表中,可以加const修饰形参,防止形参改变实参
#include<iostream>
//引用使用的场景,通常用来修饰形参
void showValue(const int& v) {
//v = 10; //v为常量,故不可修改
std::cout << v << std::endl;
}
int main() {
//int& ref = 10; 引用本身需要一个合法的内存空间,因此这行错误
//加入const就可以了,编译器优化代码,int temp = 10; const int& ref = temp;
const int& ref = 10;
//ref = 100; //加入const后不可以修改变量
std::cout << ref << std::endl;
//函数中利用常量引用防止误操作修改实参
int a = 10;
showValue(a);
return 0;
}
6 类和对象
C++面向对象的三大特性为:封装、继承、多态
6.1 封装
封装是C++面向对象三大特性之一
封装的意义:
-
将属性和行为作为一个整体,表现生活中的事物
-
将属性和行为加以权限控制
访问权限:
public 公共权限-----类内可以访问 类外可以访问
protected 保护权限-----类内可以访问 类外不可以访问;子集可以访问父级
private 私有权限-----类内可以访问 类外不可以访问;子集不可以访问父级
将成员属性设为私有
-
优点1:可以自己控制读写权限
-
优点2:对于写权限,我们可以检测数据的有效性
6.2 struct(结构体)和class(类)区别
在C++中 struct和class唯一的区别就在于 默认的访问权限不同
- struct (结构体) 默认权限为 公共public
- class (类) 默认权限为 私有private
6.3 对象的初始化和清理
6.3.1 构造函数和析构函数
对象的初始化和清理也是两个非常重要的安全问题
一个对象或者变量没有初始状态,对其使用后果是未知
同样的使用完一个对象或变量,没有及时清理,也会造成—定的安全问题
C++利用了构造函数和析构函数解决上述问题,这两个函数将会被编译器自动调用,完成对象初始化和清理工作。对象的初始化和清理工作是编译器强制要我们做的事情,因此如果我们不提供构造和析构,编译器会提供
编译器提供的构造函数和析构函数是空实现。
- 构造函数:主要作用在于创建对象时为对象的成员属性赋值,构造函数由编译器自动调用,无须手动调用。
- 析构函数:主要作用在于对象销毁前系统自动调用,执行一些清理工作。
#include<iostream>
//对象初始化和清理
class Person {
public:
//构造函数 可以有参数,也可以没有参数,因此可以重载
Person() {
std::cout << "构造函数" << std::endl;
}
//析构函数(进行清理的操作)
// 析构函数不可以有参数,因此不发生重载
//对象在销毁前 会自动调用析构函数,而且只会调用一次
~Person() {
std::cout << "析构函数" << std::endl;
}
};
//构造和析构都是必须有的实现,如果我们自己不提供,编译器会提供一个空的构造和析构
void test01() {
Person p; //在栈上的数据,test01执行完毕后,释放这个对象
}
int main() {
test01();
}
6.3.2 构造函数的分类及调用
默认情况下,C++编译器至少给一个类添加3个函数:
- 默认构造函数(无参,函数体为空)
- 默认析构函数(无参,函数体为空)
- 默认拷贝构造函数,对属性进行值拷贝
构造函数调用规则如下:
- 如果用户定义有参构造函数,c++不在提供默认无参构造,但是会提供默认拷贝构造
- 如果用户定义拷贝构造函数,c++不会再提供其他构造函数
#include<iostream>
/*
构造函数分类:
按照参数分类:有参和无参构造 无参又称为默认构造函数
按照类型分类:普通构造和拷贝构造
*/
class Person {
public:
//无参构造
Person() {
}
//有参构造
Person(int age) {
}
//拷贝构造
Person(const Person &p) {
}
};
void test01() {
//1、括号法
//注意事项:调用默认构造函数时,不要加() 因为类似 Person p1(); 编译器会认为这是一个函数的声明,不会认为这是在创建对象
Person p1;
Person p2(10);
Person p3(p2);
//2、显示法
//注意事项:不要利用拷贝构造函数 初始化匿名对象 编译器会认为 Person (p3) === Person p3
Person p4;
Person p5 = Person(10);
Person p6 = Person(p5);
//3、隐匿转换法
Person p7 = 10;
Person p8 = p7;
}
int main() {
test01();
return 0;
}
6.3.3 深拷贝与浅拷贝
浅拷贝:简单的赋值拷贝操作
深拷贝:在堆区重新申请空间,进行拷贝操作
#include<iostream>
class Person {
public:
Person() {
}
Person(int age,int height) {
m_age = age;
m_height = new int(height);
}
//拷贝构造函数
Person(const Person &p) {
m_age=p.m_age;
//m_height=p.m_height; //编译器默认实现的拷贝函数就是实现这行代码
m_height = new int(*p.m_height); //深拷贝操作
}
~Person() {
//析构代码,将堆区开辟数据做释放
if(m_height != NULL) {
delete m_height;
m_height=NULL;
}
}
public:
int m_age;
int *m_height;
};
void test() {
Person p1(10,160);
std::cout << p1.m_age << " " << *p1.m_height << std::endl;
Person p2(p1);
/*
Person p2(p1);
如果是默认的析构函数,则p1与p2的m_height指向的内存时同一块,因此在p2释放完m_height的内存之后,p1再进行释放,就会出现错误
因此就用到了深拷贝,为他们分配两块不同的内存,因此他们释放并不会有什么问题
p2比p1先释放是因为他们保存在栈区,栈的特点是先进后出
*/
std::cout << p2.m_age << " " << *p2.m_height << std::endl;
}
int main() {
test();
}
6.3.4 静态成员
静态成员就是在成员变量和成员函数前加上关键字static,称为静态成员。
静态成员分为:
-
静态成员变量
- 所有对象共享同一份数据
- 在编译阶段分配内存
- 类内声明,类外初始化
-
静态成员函数
- 所有对象共享同一个函数
- 静态成员函数只能访问静态成员变量
#include<iostream>
//静态成员函数
//所有对象共享同一个函数
//静态成员函数只能访问静态成员变量
class Person {
public:
static void func() {
std::cout << "static void func" << std::endl;
std::cout << cnt << std::endl;//静态成员函数可以访问静态成员变量
// std::cout << No << std::endl; 静态成员函数不可以访问非静态成员变量 无法区分到底是哪个对象的No
}
Person() {
cnt++;
No=cnt;
}
void print() {
std::cout << "PersonNumber:" << No << " " << "totalCount:" << cnt << std::endl;
}
int No;
static int cnt;
private:
static void func2() {
std::cout << "static void fun2" << std::endl;
}
//私有作用域下类外访问不到
};
int Person::cnt=0; //类内声明,类外初始化
int main() {
//1、通过对象访问
Person p1;
p1.func();
//2、通过类名访问
Person::func();
Person p2;
Person p3;
p1.print();
p2.print();
p3.print();
return 0;
}
static void func
1
static void func
1
PersonNumber:1 totalCount:3
PersonNumber:2 totalCount:3
PersonNumber:3 totalCount:3
拓展:
在类内部成员的声明前加上 static,即该成员就是类内部的静态数据成员,有以下特点:
- 静态数据成员是类成员;(无论类的对象被定义了多少个,静态数据成员在程序中也只有一份复制品)
- 静态数据成员存储在全局数据区,属于本类的所有对象共享,不属于特定的类对象;
- static 成员变量的初始化在类外,不能加上 private 。
问题1:为什么 static 变量只初始化一次?
对于所有的变量都只初始化一次,而由于静态变量具有 “记忆” 功能,初始化后,一直都没有被销毁,都会保存在内存区域中,所以不会再次初始化。静态变量存在全局区中,只有程序结束后才会被回收,所有只要初始化一次就够了。
问题2:在头文件中定义静态变量,是否可行?为什么?
不可行,如果在头文件中定义静态变量,会造成资源浪费的问题,同时也可能引起程序的错误。因为如果在使用了该头文件的每个 C 语言文件中定义静态变量,在每个头文件中都会单独存在一个静态变量,从而会引起空间浪费或者程序出错。
6.4 C++对象模型和this指针
6.4.1 成员变量和成员函数分开存储
在C++中,类内的成员变量和成员函数分开存储
只有非静态成员变量才属于类的对象
#include<iostream>
class A {
};
class Person {
int m_A; // 非静态成员变量 属于类的对象上
static int m_B; //静态成员变量 不属于类对象上
void func1() {}; //非静态成员函数 不属于类对象上
static void func2() {} //静态成员函数
};
int Person::m_B=0;
int main() {
A a;
std::cout << "空对象占: " << sizeof(a) << std::endl;
//空对象占用内存空间为: 1
//C++编译器会给每个空对象也分配一个字节空间,是为了区分空对象占内存的位置
//每个空对象也应该有一个独一无二的内存地址
Person p;
std::cout << "占: " << sizeof(p) << std::endl;
//占4个字节 只有非静态成员变量才属于类的对象 所以只算m_A大小
return 0;
}
6.4.2 this指针概念
我们知道在C++中成员变量和成员函数是分开存储的
每一个非静态成员函数只会诞生一份函数实例,也就是说多个同类型的对象会共用一块代码
那么问题是:这—块代码是如何区分那个对象调用自己的呢?
C++通过提供特殊的对象指针,this指针,解决上述问题。this指针指向被调用的成员函数所属的对象
this指针是隐含每一个非静态成员函数内的一种指针
this指针不需要定义,直接使用即可
this指针的用途:
- 当形参和成员变量同名时,可用this指针来区分
- 在类的非静态成员函数中返回对象本身,可使用return *this
#include<iostream>
class Person {
public:
Person(int age) {
//当形参和变量同名时,可用this指针来区分
this->age = age;
}
Person& PersonAddAge(Person &p) {
this->age += p.age;
//返回对象本身用*this
return *this;
}
int age;
};
int main() {
Person p1(10);
Person p2(20);
//链式编程思想
p2.PersonAddAge(p1).PersonAddAge(p1).PersonAddAge(p1);
std::cout << p2.age << std::endl;
return 0;
}
6.4.3 空指针访问成员函数
C++中空指针也是可以调用成员函数的,但是也要注意有没有用到this指针
如果用到this指针,需要加以判断保证代码的健壮性
#include<iostream>
class Person {
public:
void func() {
std::cout << "this is func" << std::endl;
}
void showAge() {
//若this为空指针,增加此行,加强程序的健壮性
if(this == NULL) {
return ;
}
std::cout << this->m_Age << std::endl;
}
int m_Age;
};
int main() {
Person * p = NULL;
p->func();
p->showAge();
return 0;
}
6.4.4 const修饰成员函数
常函数:
- 成员函数后加const后我们称为这个函数为常函数
- 常函数内不可以修改成员属性
- 成员属性声明时加关键字mutable后,在常函数中依然可以修改
常对象:
-
声明对象前加const称该对象为常对象
-
常对象只能调用常函数
#include<iostream>
class Person {
public:
Person():m_A(10){
}
void func() {
m_B=10;
std::cout << "this is func" << std::endl;
}
//this指针得本质 是指针常量 指针的指向是不可以修改的
//this指针相当于 Person * const this
//在成员函数后面加const,修饰的是this得指向,因此this指针就变为const Person * const this,让指针指向的值也不可以修改
void showAge() const {
//this->m_A = 100; × 常含数不能更改普通变量,但是添加关键字mutable后仍可更改
std::cout << "this is const func" << std::endl;
this->m_B = 100;
}
int m_A;
mutable int m_B;
};
int main() {
const Person p;
//p.m_A = 100; ×
p.m_B = 100; //m_B是mutable关键字,在常对象和常含数中都可以更改
//常对象只能调用常含数
p.showAge();
//p.func(); ×
return 0;
}
6.5 友元
在程序中,有些特殊的类或者函数也想要访问类内的私有属性,此时就应该用到友元。
友元的目的就是让一个函数或类访问另一个类中的私有属性。
关键字:friend
友元的三种实现
-
全局函数做友元
-
类做友元
-
成员函数做友元
6.5.1 全局函数做友元
如果类想把全局函数作为友元只需要在类内加入一条以friend开头的函数声明即可。
#include<iostream>
class Person {
public:
friend void visitPrivate(Person *p);
Person() {
m_A = 10;
m_B = 20;
}
public:
int m_A;
private:
int m_B;
};
void visitPrivate(Person *p) {
std::cout << p->m_A << std::endl;
std::cout << p->m_B << std::endl;
}
int main() {
Person p;
visitPrivate(&p);
return 0;
}
6.5.2 类做友元
类内加入一条以friend开头的类声明即可。
6.6 运算符重载
运算符重载概念:对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型
6.6.1 加号运算符重载
作用:实现两个自定义数据类型相加的运算
#include<iostream>
class Person {
public:
//成员函数重载+号
Person operator+(Person &p) {
Person temp;
temp.m_A = this->m_A + p.m_A;
temp.m_B = this->m_B + p.m_B;
return temp;
}
public:
int m_A;
int m_B;
};
/*
全局函数重载+号
Person operator+(Person &p1,Person &p2) {
Person temp;
temp.m_A = p1.m_A + p2.m_A;
temp.m_B = p2.m_B + p2.m_B;
return temp;
}
*/
int main() {
Person p1;
p1.m_A = 10;
p1.m_B = 10;
Person p2;
p2.m_A = 10;
p2.m_B = 10;
//成员函数重载的本质调用 Person p3 = p1.operator+(p2);
//全局函数重载的本质调用 Person p3 = operator+(p1,p2);
Person p3 = p1 + p2;
std::cout << p3.m_A << " " << p3.m_B << std::endl;
return 0;
}
总结1:对于内置的数据类型的表达式的的运算符是不可能改变的
总结2:不要滥用运算符重载
6.6.2 左移运算符重载
作用:可以输出自定义类型
#include<iostream>
class Person {
friend std::ostream & operator<< (std::ostream &out,Person &p);
public:
//利用成员函数重载左移运算符 p.operator<< (cout) 简化版本 p << cout
//不会利用成员函数重载<< 运算符,因为无法实现cout在左侧
Person(int a,int b) {
this->m_A = a;
this->m_B = b;
}
private:
int m_A;
int m_B;
};
//只能运用全局函数重载左移运算符
//本质 operator<< (std::cout , p) 简化std::cout << p;
std::ostream & operator<< (std::ostream &out,Person &p) {
out << p.m_A << " " << p.m_B << std::endl;
return out;
}
int main() {
Person p(10,10);
std::cout << p;
return 0;
}
6.6.3 递增运算符重载
注意前置递增和后置递增的区别
6.6.4 赋值运算符重载
如果类中有属性指向堆区,做赋值操作时也会出现深浅拷贝问题
因此重载赋值运算符来解决这个问题
6.6.5关系运算符重载
6.6.6 函数调用运算符重载
6.7 继承
6.7.1 继承基本语法
继承的好处:减少重复代码
语法: class 子类:继承方式 父类
子类 也称为 派生类
父类 也称为 基类
派生类中的成员,包含两大部分:
类是从基类继承过来的,一类是自己增加的成员。
从基类继承过过来的表现其共性,而新增的成员体现了其个性。
6.7.2 继承方式
语法: class 子类:继承方式 父类
继承方式有三种:
-
公共继承
-
保护继承
-
私有继承
公共继承:
可以访问父类中公共属性和保护属性,且在父类中的属性到子类中是不变的,但是不能访问父类中的私有属性。
保护继承:
可以访问父类中公共属性和保护属性,且在父类中的公共属性到子类中变为保护属性,保护属性还是保护属性,但是不能访问父类中的私有属性。
私有继承:
可以访问父类中公共属性和保护属性,且在父类中的公共属性到子类中变为私有属性,保护属性到子类中变为私有属性,但是不能访问父类中的私有属性。
所有继承中父类私有权限的属性子类都是不能访问的。只是访问不到,但是他是有继承下去的,只是被编译器给隐藏了。
6.7.3 继承中的对象模型
#include<iostream>
class Base {
public:
int m_A;
protected:
int m_B;
private:
int m_C;
};
class Test : public Base {
public:
int m_D;
};
int main() {
//父类中所有非静态成员属性都会被子类继承下去
//父类中私有成员属性是被编译器给隐藏了,因此是访问不到,但是确实被继承下去了
std::cout << sizeof(Test) << std::endl;
return 0;
}
//输出16
6.7.4 继承中的构造和析构顺序
先构造父类,再构造子类,然后析构子类,再析构父类
6.7.5 继承同名成员处理方式
问题:当子类与父类出现同名的成员,如何通过子类对象,访问到子类或父类中同名的数据呢?
- 访问子类同名成员直接访问即可
- 访问父类同名成员需要加作用域
总结:
- 子类对象可以直接访问到子类中同名成员
- 子类对象加作用域可以访问到父类同名成员
- 当子类与父类拥有同名的成员函数,子类会隐藏父类中同名成员函数,加作用域可以访问到父类中同名函数
6.7.6 继承同名静态成员处理方式
6.7.7 多继承语法
C++允许一个类继承多个类
语法:class 子类:继承方式 父类1,继承方式 父类2 ,…
多继承可能会引发父类中有同名成员出现,需要加作用域区分
C++实际开发中不建议用多继承
6.7.8 菱形继承
6.8 多态
6.8.1 多态的基本概念
多态分为两类:
-
静态多态:函数重载和运算符重载属于静态多态,复用函数名
-
动态多态:派生类和虚函数实现运行时多态
静态多态和动态多态的区别:
- 动态多态的函数早绑定 编译阶段确定函数的地址
- 动态多态的函数地址晚绑定 运行阶段确定函数地址
代码示例:
#include<iostream>
class Animal {
public:
//speak函数就是虚函数
//函数前面加上virtual关键字,变成虚函数,那么编译器在编译的时候就不能确实函数调用了
virtual void speak() {
std::cout << "动物在叫" << std::endl;
}
};
class Cat : public Animal {
public:
void speak() {
std::cout << "小猫在叫" << std::endl;
}
};
class Dog : public Animal {
public:
void speak() {
std::cout << "小狗在叫" << std::endl;
}
};
void Say(Animal& animal) {
animal.speak();
}
/*
多态满足的条件
1、有继承关系
2、子类重写父类的虚函数
多态的使用:
父类指针或引用指向子类对象
*/
void test() {
Cat cat;
Say(cat);
Dog dog;
Say(dog);
}
int main() {
test();
return 0;
}
总结:
多态满足的条件
- 有继承关系
- 子类重写父类中的虚函数
多态的使用条件
- 父类指针或引用指向子类对象
重写:函数返回值类型 函数名 参数列表 完全一致称为重写
多态的实现原理:
-
用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
-
存在虚函数的类都有一个一维的虚函数表叫做虚表。当类中声明虚函数时,编译器会在类中生成一个虚函数表。
-
类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
-
虚函数表是一个存储类成员函数指针的数据结构。
-
虚函数表是由编译器自动生成与维护的。
-
virtual成员函数会被编译器放入虚函数表中。
-
当存在虚函数时,每个对象中都有一个指向虚函数的指针(C++编译器给父类对象,子类对象提前布局vptr指针),当进行test(parent *base)函数的时候,C++编译器不需要区分子类或者父类对象,只需要再base指针中,找到vptr指针即可)。
-
vptr一般作为类对象的第一个成员。
总结(基类有虚函数):
- 每一个类都有虚表。
- 虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类有3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
- 派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。
这就是C++中的多态性。当C++编译器在编译的时候,发现animal类的breathe()函数是虚函数,这个时候C++就会采用迟绑定(late binding)技术。也就是编译时并不确定具体调用的函数,而是在运行时,依据对象的类型(在程序中,我们传递的fish类对象的地址)来确认调用的是哪一个函数,这种能力就叫做C++的多态性。我们没有在breathe()函数前加virtual关键字时,C++编译器在编译时就确定了哪个函数被调用,这叫做早期绑定(early binding)。
C++的多态性是通过迟绑定技术来实现的。
C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
6.8.2 纯虚函数和抽象类
在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容,因此可以将虚函数改为纯虚函数。
纯虚函数语法:virtual 返回值类型函数名(参数列表) = 0 ;
当类中有了纯虚函数,这个类也称为抽象类
抽象类特点:
- 无法实例化对象
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类
6.8.2 虚析构和纯虚析构
多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码
解决方式:将父类中的析构函数改为虚析构或者纯虚析构
虚析构和纯虚析构共性:
- 可以解决父类指针释放子类对象
- 都需要有具体的函数实现
虚析构和纯虚析构区别:
- 如果是纯虚析构,该类属于抽象类,无法实例化对象
虚析构语法:
virtual ~类名(){}
纯虚析构语法:
virtual ~类名()= 0;
类名::~类名(){}
示例代码:
#include<iostream>
#include<string>
class Animal {
public:
Animal() {
std::cout << "Animal的构造函数" << std::endl;
}
//虚析构
// 利用虚析构可以解决父类指针释放子类对象时不干净的问题
//virtual ~Animal() {
// std::cout << "Animal的虚析构函数" << std::endl;
//}
//纯虚析构 需要声明页需要实现
virtual ~Animal() = 0;
virtual void speak() = 0;
};
class Cat : public Animal {
public:
Cat(std::string name) {
m_Name = new std::string(name);
std::cout << "Cat的构造函数" << std::endl;
}
~Cat() {
if (m_Name != NULL) {
delete m_Name;
m_Name = NULL;
}
std::cout << "Cat的析构函数" << std::endl;
}
void speak() {
std::cout << "猫在叫" << std::endl;
}
std::string * m_Name;
};
Animal::~Animal() {
std::cout << "Animal的纯虚析构" << std::endl;
}
void test() {
Animal * cat = new Cat("QQQ");
cat->speak();
//父类指针在析构的时候 不会调用子类中的析构函数,导致子类如果有堆区属性,则会出现内存泄露
delete cat;
}
int main() {
test();
return 0;
}
总结:
-
虚析构或纯虚析构就是用来解决通过父类指针释放子类对象
-
如果子类中没有堆区数据,可以不写为虚析构或纯虚析构
-
拥有纯虚析构函数的类也属于抽象类
7 模板
7.1 函数模板
- C++另一种编程思想称为泛型编程,主要利用的技术就是模板
- C++提供两种模板机制:函数模板和类模板
7.1.1 函数模板的语法
函数模板作用:建立一个通用函数,其函数返回值类型和形参类型可以不具体制定,用一个虚拟的类型来代表。
语法:
template
函数声明或定义
解释:
template —声明创建模板
typename —表面其后面的符号是一种数据类型,可以用class代替
T —通用的数据类型,名称可以替换,通常为大写字母
示例代码:
#include<iostream>
//函数模板
template<typename T> //声明一个模板,告诉编译器后面代码中紧跟着的T不用报错,T是一个通用数据类型
void Swap(T &a,T &b) {
T temp = a;
a = b;
b = temp;
}
int main() {
int a = 10;
int b = 20;
//两种方式使用函数模板
//1、自动类型推导
Swap(a,b);
//2、显示指定类型
Swap<int>(a,b);
std::cout << a << " " << b << std::endl;
double c = 1.1;
double d = 1.2;
Swap(c,d);
Swap<double>(c,d);
std::cout << c << " " << d << std::endl;
return 0;
}
总结:
-
函数模板利用关键字template
-
使用函数模板有两种方式:自动类型推导、显示指定类型
-
模板的目的是为了提高复用性,将类型参数化
7.1.2 注意事项
-
自动类型推导,必须推导出一致的数据类型T,才可以使用
-
模板必须要确定出T的数据类型,才可以使用
7.1.3 普通函数与函数模板的区别
-
普通函数调用时可以发生自动类型转换(隐式类型转换)
-
函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换
-
如果利用显示指定类型的方式,可以发生隐式类型转换
7.1.4 普通函数与函数模板的调用规则
-
如果函数模板和普通函数都可以实现,优先调用普通函数
-
可以通过空模板参数列表来强制调用函数模板
-
函数模板也可以发生重载
-
如果函数模板可以产生更好的匹配,优先调用函数模板
总结:既然提供了函数模板,最好就不要提供普通函数,否则容易出现二义性
7.2 类模板
类模板作用:建立一个通用类,类中的成员数据类型可以不具体制定,用一个虚拟的类型来代表。
7.2.1 基本语法
类模板基本语法与函数模板基本相同
示例代码:
#include<iostream>
#include<string>
//当name和age类型不一样时,可以指定两种类型
template<class NameType,class AgeType>
class Person {
public:
Person(NameType name,AgeType age) {
this->m_Name = name;
this->m_Age = age;
}
void show() {
std::cout << this->m_Name << std::endl;
std::cout << this->m_Age << std::endl;
}
NameType m_Name;
AgeType m_Age;
};
int main() {
//指定NameType为string类型,AgeType为int类型
Person<std::string,int> p1("QQQ",20);
p1.show();
return 0;
}
7.2.2 类模板与函数模板的区别
- 类模板没有自动类型推导的使用方式
Person p1("QQQ",20); //错误,类模板没有自动类型推导的使用方式
- 类模板在模板参数列表中可以有默认参数
template<class NameType,class AgeType = int>
定义时:
Person<int> p1("QQQ",20);
7.2.3 类模板中成员函数的创建时机
-
普通类中的成员函数一开始就可以创建
-
类模板中的成员函数在调用时才创建
7.2.4 类模板对象做函数参数
-
指定传入的类型 — 直接显示对象的数据类型
-
参数模板化 — 将对象中的参数变为模板进行传递
-
整个类模板化 — 将这个对象类型 模板化进行传递
示例代码:
#include <iostream>
#include <string>
//类模板
template<class NameType, class AgeType = int>
class Person
{
public:
Person(NameType name, AgeType age)
{
this->mName = name;
this->mAge = age;
}
void showPerson()
{
std::cout << "name: " << this->mName << " age: " << this->mAge << std::endl;
}
public:
NameType mName;
AgeType mAge;
};
//1、指定传入的类型
void printPerson1(Person<std::string, int> &p)
{
p.showPerson();
}
void test01()
{
Person <std::string, int >p("孙悟空", 100);
printPerson1(p);
}
//2、参数模板化
template <class T1, class T2>
void printPerson2(Person<T1, T2> &p)
{
p.showPerson();
std::cout << "T1的类型为: " << typeid(T1).name() << std::endl;
std::cout << "T2的类型为: " << typeid(T2).name() << std::endl;
}
void test02()
{
Person <std::string, int >p("猪八戒", 90);
printPerson2(p);
}
//3、整个类模板化
template<class T>
void printPerson3(T & p)
{
std::cout << "T的类型为: " << typeid(T).name() << std::endl;
p.showPerson();
}
void test03()
{
Person <std::string, int >p("唐僧", 30);
printPerson3(p);
}
int main() {
test01();
test02();
test03();
system("pause");
return 0;
}
总结
- 通过类模板创建的对象,可以有三种方式向函数中进行传参
- 使用比较广泛是第一种:指定传入的类型
7.2.5 类模板与继承
当类模板碰到继承时,需要注意一下几点:
-
当子类继承的父类是一个类模板时,子类在声明的时候,要指定出父类中T的类型
-
如果不指定,编译器无法给子类分配内存
-
如果想灵活指定出父类中T的类型,子类也需变为类模板
7.2.6 类模板分文件编写
问题:
- 类模板中成员函数创建时机是在调用阶段,导致分文件编写时链接不到
解决:
- 解决方式1:直接包含.cpp源文件
- 解决方式2:将声明和实现写到同一个文件中,并更改后缀名为.hpp,hpp是约定的名称,并不是强制
示例代码:
person.hpp
#pragma once
#include <iostream>
#include <string>
template<class T1, class T2>
class Person {
public:
Person(T1 name, T2 age);
void showPerson();
public:
T1 m_Name;
T2 m_Age;
};
//构造函数 类外实现
template<class T1, class T2>
Person<T1, T2>::Person(T1 name, T2 age) {
this->m_Name = name;
this->m_Age = age;
}
//成员函数 类外实现
template<class T1, class T2>
void Person<T1, T2>::showPerson() {
std::cout << "姓名: " << this->m_Name << " 年龄:" << this->m_Age << std::endl;
}
test.cpp
#include<iostream>
#include<string>
//#include "person.h"
//#include "person.cpp" //解决方式1,包含cpp源文件
//解决方式2,将声明和实现写到一起,文件后缀名改为.hpp
#include "person.hpp"
void test01()
{
Person<std::string, int> p("QQQ", 20);
p.showPerson();
}
int main() {
test01();
return 0;
}
7.2.7 类模板与友元
类模板配合友元函数的类内和类外实现
- 全局函数类内实现 :直接在类内声明友元即可
- 全局函数类外实现 :需要提前让编译器知道全局函数的存在
示例代码:
#include <iostream>
#include <string>
//2、全局函数配合友元 类外实现 - 先做函数模板声明,下方再做函数模板定义,再做友元
template<class T1, class T2> class Person;
//如果声明了函数模板,可以将实现写到后面,否则需要将实现体写到类的前面让编译器提前看到
//template<class T1, class T2> void printPerson2(Person<T1, T2> & p);
template<class T1, class T2>
void printPerson2(Person<T1, T2>& p)
{
std::cout << "类外实现 ---- 姓名: " << p.m_Name << " 年龄:" << p.m_Age << std::endl;
}
template<class T1, class T2>
class Person
{
//1、全局函数配合友元 类内实现
friend void printPerson(Person<T1, T2>& p)
{
std::cout << "姓名: " << p.m_Name << " 年龄:" << p.m_Age << std::endl;
}
//全局函数配合友元 类外实现
friend void printPerson2<>(Person<T1, T2>& p);
public:
Person(T1 name, T2 age)
{
this->m_Name = name;
this->m_Age = age;
}
private:
T1 m_Name;
T2 m_Age;
};
//1、全局函数在类内实现
void test01()
{
Person <std::string, int >p("Tom", 20);
printPerson(p);
}
//2、全局函数在类外实现
void test02()
{
Person <std::string, int >p("Jerry", 30);
printPerson2(p);
}
int main() {
test01();
test02();
return 0;
}
总结
- 建议全局函数做类内实现,用法简单,而且编译器可以直接识别