学习C++时遇到的与其它编程语言中的不同与难点
C++是目前公认的比较难学的一门编程语言,因为其中的知识点过于复杂和繁琐,连谷歌设计Golang的时候,都将目标设定为:“不要像C++。”
那么既然这么难学,为什么还要学它呢,是因为它在某些领域拥有非常大的优势:
- 游戏服务端开发
- 游戏开发
- 数字图像处理
- 科学计算
- 网络软件
- 分布式应用
- 操作系统
- 嵌入式系统
- 教育与科研
- …
尤其是现在虚幻引擎在世界范围内逐渐升温,Cocos2d-x也需要使用C++进行开发。所以C++的应用范围非常的广泛,其实编程语言没有什么孰好孰坏之分,只有合适与不合适的区别。
本文的运行环境为32位操作系统,C++开发默认运行环境即为32位操作系统,下面文章中所提到的占用内存空间统统都指的32位运行环境。
1. 指针(非常重要)
目前学习C++时遇到的和其它编程语言最大的不同点,就是指针。指针可能单独作为一部分来看并不难,难点是后面的值传递,地址传递,引用传递,深拷贝,浅拷贝这些知识点使用到指针的时候,就感觉突然从1+1=2
的题目上升到微积分。
指针的作用: 可以通过指针间接访问内存
- 内存编号是从0开始记录的,一般用十六进制数字表示
- 可以利用指针变量保存地址
指针变量定义语法: 数据类型 * 变量名;
- 普通变量存放的是数据,指针变量存放的是地址
- 指针变量可以通过” * “操作符,操作指针变量指向的内存空间,这个过程称为解引用
1.1 指针所占内存
在不同的系统下指针所占的内存不同,一般情况下所有指针类型在32位操作系统下是4个字节,在64位系统下使8个字节。
1.2 空指针和野指针
空指针:指针变量指向内存中编号为0的空间
用途:初始化指针变量
注意:空指针指向的内存是不可以访问的
int main() {
//指针变量p指向内存地址编号为00000000的空间
int * p = NULL;
//访问空指针报错
//内存编号0 ~255为系统占用内存,不允许用户访问
cout << *p << endl;
system("pause");
return 0;
}
野指针:指针变量指向非法的内存空间即指向了系统占用内存空间。
int main() {
//指针变量p指向内存地址编号为0x1100的空间
int * p = (int *)0x1100;
//访问野指针报错
cout << *p << endl;
system("pause");
return 0;
}
空指针和野指针都不是我们申请的空间,因此不要访问。
1.3 const修饰指针
const修饰指针有三种情况
- const修饰指针 — 常量指针
- const修饰常量 — 指针常量
- const即修饰指针,又修饰常量
int main() {
int a = 10;
int b = 10;
//const修饰的是指针,指针指向可以改,指针指向的值不可以更改
const int *p1 = &a;
p1 = &b; //正确
//*p1 = 100; 报错
//const修饰的是常量,指针指向不可以改,指针指向的值可以更改
int *const p2 = &a;
//p2 = &b; //错误
*p2 = 100; //正确
//const既修饰指针又修饰常量
const int *const p3 = &a;
//p3 = &b; //错误
//*p3 = 100; //错误
system("pause");
return 0;
}
1.4 指针和数组
因为数组在内存上面开辟的是一块连续的运行空间,而数组的变量也储存的数组所在内存的地址,所以我们可以通过指针来遍历数组。
int main() {
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = arr; //指向数组的指针
cout << "第一个元素: " << arr[0] << endl;
cout << "指针访问第一个元素: " << *p << endl;
for (int i = 0; i < 10; i++) {
//利用指针遍历数组
cout << *p << endl;
p++;
}
system("pause");
return 0;
}
又因为数组的变量名记录的是它的地址,那么问题又来了。
void change(int arr[]) {
for (int i = 0; i < 10; ++i) {
arr[i] = 10;
}
}
int main() {
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
change(arr);//调用改变函数
int *p = arr; //指向数组的指针
cout << "第一个元素: " << arr[0] << endl;
cout << "指针访问第一个元素: " << *p << endl;
for (int i = 0; i < 10; i++) {
//利用指针遍历数组
cout << *p << endl;
p++;
}
system("pause");
return 0;
}
如果我们在函数change中改变了arr的值,那么在main函数中遍历会输出什么结果?
第一个元素: 10
指针访问第一个元素: 10
10
10
10
10
10
10
10
10
10
10
可以看到,在change函数中改变了arr中的值,main函数中的arr也发生了改变。
1.5 指针和函数(非常重要)
这个在其它编程语言中使用时出现的情况也非常的多,虽然其它编程语言中不操作指针,但是涉及到值传递和引用传递,原理上和C++的指针一样。
在一般情况下,形参是不能修饰实参的。例如:
void change(int a) {
a = 1000;
cout << "change函数里的a值为" << a << endl;
}
int main() {
int a = 100;
change(a);
cout << "main函数里的a值为" << a << endl;
system("pause");
return 0;
}
那么这个函数里的a和函数外的a输出分别是多少?
//change函数里的a值为1000
//main函数里的a值为100
可以看到,即便在change函数里面修改了a的值,但main函数中的a值不会发生改变。
那么下面的例子change函数里的a和main函数里的a输出分别又是多少?
void change(int *a) {
*a = 1000;
cout << "change函数里的*a值为" << *a << endl;
}
int main() {
int a = 100;
change(&a);
cout << "main函数里的a值为" << a << endl;
system("pause");
return 0;
}
可以看到上面只将主函数中的变量a的地址传入到change函数中,那么运行后主函数中a的值又是多少?
change函数里的*a值为1000
main函数里的a值为1000
可以看到main函数里的a值被改变了。
总结:如果不想修改实参,就用值传递,如果想修改实参,就用地址传递。
1.6 指针总结
无论在哪门编程语言中,指针的指向都是非常重要必须要进行理解的,建议多看看相关类型的资料,因为对于初学者而言,指针并不是那么容易理解,必须在经过大量的练习后,才能融会贯通。
2. 引用
通过引用可以给变量起别名。
语法:数据类型 &别名 = 原名
2.1 注意事项
- 引用必须初始化
- 引用在初始化后,不可以改变
2.2 引用做函数参数
上文也提到过参数有3种传递方式:
- 值传递
- 地址传递
- 引用传递
作用:函数传参时,可以利用引用的技术让形参修饰实参
优点:可以简化指针修改实参
//1. 值传递
void mySwap01(int a, int b) {
int temp = a;
a = b;
b = temp;
}
//2. 地址传递
void mySwap02(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
//3. 引用传递
void mySwap03(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int a = 10;
int b = 20;
mySwap01(a, b);
cout << "a:" << a << " b:" << b << endl;
mySwap02(&a, &b);
cout << "a:" << a << " b:" << b << endl;
mySwap03(a, b);
cout << "a:" << a << " b:" << b << endl;
system("pause");
return 0;
}
总结:通过引用参数产生的效果同按地址传递是一样的。引用的语法更清楚简单。
2.3 引用做函数返回值
注意:不要返回局部变量引用,因为局部变量引用会在函数结束调用后进行销毁。
用法:函数调用作为左值
2.4 引用的本质
//发现是引用,转换为 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;
cout << "a:" << a << endl;
cout << "ref:" << ref << endl;
func(a);
return 0;
}
引用的本质在c++内部实现是一个指针常量.
2.5 常量引用
作用:常量引用主要用来修饰形参,防止误操作
在函数形参列表中,可以加const修饰形参,防止形参改变实参,例如:const int& ref = 10;
3. 函数声明
3.1 函数默认参数
C++中,函数的形参列表中的形参可以拥有默认值。
语法:返回值类型 函数名 (参数= 默认值){}
int func(int a, int b = 10, int c = 10) {
return a + b + c;
}
//1. 如果某个位置参数有默认值,那么从这个位置往后,从左向右,必须都要有默认值
//2. 如果函数声明有默认值,函数实现的时候就不能有默认参数
int func2(int a = 10, int b = 10);
int func2(int a, int b) {
return a + b;
}
int main() {
cout << "ret = " << func(20, 20) << endl;
cout << "ret = " << func(100) << endl;
system("pause");
return 0;
}
3.2 占位参数
C++中函数的形参列表里可以有占位参数,用来做占位,调用函数时必须填补该位置
语法: 返回值类型 函数名 (数据类型){}
主要用在类的多态。
//函数占位参数 ,占位参数也可以有默认参数
void func(int a, int) {
cout << "this is func" << endl;
}
int main() {
func(10,10); //占位参数必须填补
system("pause");
return 0;
}
3.3 函数的重载
作用:函数名可以相同,提高复用性
函数重载满足条件:
- 同一个作用域下
- 函数名称相同
- 函数参数类型不同 或者 个数不同 或者 顺序不同
//函数重载需要函数都在同一个作用域下
void func()
{
cout << "func 的调用!" << endl;
}
void func(int a)
{
cout << "func (int a) 的调用!" << endl;
}
void func(double a)
{
cout << "func (double a)的调用!" << endl;
}
void func(int a ,double b)
{
cout << "func (int a ,double b) 的调用!" << endl;
}
void func(double a ,int b)
{
cout << "func (double a ,int b)的调用!" << endl;
}
//函数返回值不可以作为函数重载条件
//int func(double a, int b)
//{
// cout << "func (double a ,int b)的调用!" << endl;
//}
int main() {
func();
func(10);
func(3.14);
func(10,3.14);
func(3.14 , 10);
system("pause");
return 0;
}
3.4 注意
尽量不要出现二义性:
void func2(int a, int b = 10)
{
cout << "func2(int a, int b = 10) 调用" << endl;
}
void func2(int a)
{
cout << "func2(int a) 调用" << endl;
}
上面这个函数重载就具有二义性,编译器不知道应该调用哪个函数。
4. 类和对象
面向对象的三大特性:
- 封装
- 继承
- 多态
4.1 访问权限
public
公共权限 类内可以访问 类外可以访问protected
保护权限 类内可以访问 类外不可以访问private
私有权限 类内可以访问 类外不可以访问
4.2 struct和class的区别
在C++中 struct和class唯一的区别就在于 默认的访问权限不同
区别:
- struct 默认权限为公共
- class 默认权限为私有
4.3 构造函数和析构函数
在其它高级编程语言中,构造函数经常使用,析构函数很少会用到,因为其它高级编程语言中自带垃圾回收机制(GC),而C++中则没有自动垃圾回收机制,所以需要通过析构函数进行销毁对象、变量。
- 构造函数:主要作用在于创建对象时为对象的成员属性赋值,构造函数由编译器自动调用,无须手动调用。
- 析构函数:主要作用在于对象销毁前系统自动调用,执行一些清理工作。
4.3.1 析构函数
析构函数语法: ~类名(){}
- 析构函数,没有返回值也不写void
- 函数名称与类名相同,在名称前加上符号 ~
- 析构函数不可以有参数,因此不可以发生重载
- 程序在对象销毁前会自动调用析构,无须手动调用,而且只会调用一次
4.3.2 构造函数
在C++中自带普通构造函数和拷贝构造函数。
拷贝构造函数调用:
C++中拷贝构造函数调用时机通常有三种情况
- 使用一个已经创建完毕的对象来初始化一个新对象
- 值传递的方式给函数参数传值
- 以值方式返回局部对象
4.3.3 总结
默认情况下,c++编译器至少给一个类添加3个函数
1.默认构造函数(无参,函数体为空)
2.默认析构函数(无参,函数体为空)
3.默认拷贝构造函数,对属性进行值拷贝
构造函数调用规则如下:
- 如果用户定义有参构造函数,c++不再提供默认无参构造,但是会提供默认拷贝构造
- 如果用户定义拷贝构造函数,c++不会再提供其它构造函数
4.4 深拷贝和浅拷贝(重点)
如果属性有在堆区开辟的,一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。
4.5 初始化列表
class Person {
public:
////传统方式初始化
//Person(int a, int b, int c) {
// m_A = a;
// m_B = b;
// m_C = c;
//}
//初始化列表方式初始化
Person(int a, int b, int c) :m_A(a), m_B(b), m_C(c) {}
void PrintPerson() {
cout << "mA:" << m_A << endl;
cout << "mB:" << m_B << endl;
cout << "mC:" << m_C << endl;
}
private:
int m_A;
int m_B;
int m_C;
};
int main() {
Person p(1, 2, 3);
p.PrintPerson();
system("pause");
return 0;
}
语法:构造函数():属性1(值1),属性2(值2)... {}
4.6 常函数和常对象
常函数:
- 成员函数后加const后我们称为这个函数为常函数
- 常函数内不可以修改成员属性
- 成员属性声明时加关键字
mutable
后,在常函数中依然可以修改
常对象:
- 声明对象前加const称该对象为常对象
- 常对象只能调用常函数
class Person {
public:
Person() {
m_A = 0;
m_B = 0;
}
//this指针的本质是一个指针常量,指针的指向不可修改
//如果想让指针指向的值也不可以修改,需要声明常函数
void ShowPerson() const {
//const Type* const pointer;
//this = NULL; //不能修改指针的指向 Person* const this;
//this->mA = 100; //但是this指针指向的对象的数据是可以修改的
//const修饰成员函数,表示指针指向的内存空间的数据不能修改,除了mutable修饰的变量
this->m_B = 100;
}
void MyFunc() const {
//mA = 10000;
}
public:
int m_A;
mutable int m_B; //可修改 可变的
};
//const修饰对象 常对象
void test01() {
const Person person; //常量对象
cout << person.m_A << endl;
//person.mA = 100; //常对象不能修改成员变量的值,但是可以访问
person.m_B = 100; //但是常对象可以修改mutable修饰成员变量
//常对象访问成员函数
person.MyFunc(); //常对象不能调用const的函数
}
int main() {
test01();
system("pause");
return 0;
}
4.7 友元
私有属性(Private) 也想让类外特殊的一些函数或者类进行访问,就需要用到友元(friend)
友元的三种实现
- 全局函数做友元
- 类做友元
- 成员函数做友元
4.8 运算符重载
函数名 operator运算符()
例如:Person operator+(const Person& p)
几乎所有的运算符都能进行重载。
4.9 多继承(慎用)
其它编程语言中一般为单继承,但是在C++中允许多继承。
语法:class 子类 :继承方式 父类1 , 继承方式 父类2...
多继承可能会引发父类中有同名成员出现,需要加作用域区分
4.10 菱形继承(了解)
两个派生类继承同一个基类
又有某个类同时继承者两个派生类
这种继承被称为菱形继承,或者钻石继承
总结:
- 菱形继承带来的主要问题是子类继承两份相同的数据,导致资源浪费以及毫无意义
- 利用虚继承可以解决菱形继承问题
4.11 多态
多态分为两类
- 静态多态: 函数重载 和 运算符重载属于静态多态,复用函数名
- 动态多态: 派生类和虚函数(函数前面加上virtual关键字)实现运行时多态
静态多态和动态多态区别:
- 静态多态的函数地址早绑定 - 编译阶段确定函数地址
- 动态多态的函数地址晚绑定 - 运行阶段确定函数地址
多态满足条件
- 有继承关系
- 子类重写父类中的虚函数
多态使用条件
- 父类指针或引用指向子类对象
重写:函数返回值类型 函数名 参数列表 完全一致称为重写
4.11.1 虚函数和抽象类(接口)(重要)
C++中没有接口的概念,但是可以用抽象类来实现接口。
虚函数
在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容
因此可以将虚函数改为纯虚函数
纯虚函数语法:virtual 返回值类型 函数名 (参数列表)= 0 ;
当类中有了纯虚函数,这个类也称为抽象类
抽象类特点:
- 无法实例化对象
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类
4.11.2 虚析构和纯虚析构
多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码
解决方式:将父类中的析构函数改为虚析构或者纯虚析构
虚析构和纯虚析构共性:
- 可以解决父类指针释放子类对象
- 都需要有具体的函数实现
虚析构和纯虚析构区别:
- 如果是纯虚析构,该类属于抽象类,无法实例化对象
虚析构语法:
virtual ~类名(){}
纯虚析构语法:
virtual ~类名() = 0;
类名::~类名(){}
5. 参考资料
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!