在C语言的学习过程中,我们的编程操作都是"面向过程的".例如在学习数据结构"栈"的过程中,我们自己实现了它的各种功能函数,如:初始化,销毁,出栈入栈等等.而面向对象可以说是相对于"栈"的使用者来说的,C++将这些本来需要人们自己造轮子的各种功能封装起来,只给用户对应的接口,用户只需要使用它的接口就能实现各种功能,而这些功能具体是如何实现的用户无需也无法知道.这便是"面向对象".
再以汽车举一个例子:
事实上,C++并不是完全面向对象的语言,也不是十全十美的,但它作为常年雄踞编程语言排行榜前列是有原因的,后续学习中我们会体会到C++的魅力.
上文以数据结构"栈"和汽车说明面向对象和面向过程的区别,事实上,它们在C++中就是"类"(class).在C语言中,我们使用结构体来自定义类型,但是结构体只能定义变量.而事实上类的行为是不同的.例如汽车会行驶,狗会吃东西.C++兼容了结构体的优点,同时又增加了功能以弥补它的缺点.
C++中一般用class
关键字定义一个类,类中可以定义变量,也可以定义函数.
类名+对象名
对象名.函数名()
,视情况传参面向对象编程需要依靠数据封装实现,通过使用public
,private
和protected
被称为访问修饰符的关键字标记各个区域,以指定它们被访问的权限.
有效区:从上一个访问修饰符到下一个修饰符之间,或最后一个访问修饰符到最后.
class 类名 {
public:
// 公有成员
private:
// 私有成员
protected:
// 受保护成员
};
被限定访问权限为公有(public)的成员,能在类的外部直接被访问.无需使用函数.
#include
using namespace std;
class Date
{
public:
int _year;
int GetYear();
void SetYear(int year);
};
int Date::GetYear()
{
return _year;
}
void Date::SetYear(int year)
{
_year = year;
}
int main()
{
Date date1;//实例化对象
//通过公有成员函数访问公有成员变量
date1.SetYear(2021);
cout << date1.GetYear() << endl;
//直接访问公有成员变量
date1._year = 2022;
cout << date1._year << endl;
return 0;
}
结果:
2021
2022
公有成员能在类外部直接被访问.C语言中的结构体(struct)如果不加类访问修饰符,默认是public权限,这是符合C的语法的.
类的定义有两种方式:
类名::函数名
的方式定义,类(class)的声明(包括了成员函数的声明)放在头文件(.h)中,成员函数的定义和其他放在源文件(.cpp)中.驼峰法命名规范:
_
私有(private)成员在类的外部是不可访问的.默认情况下,类的所有成员都是私有的,例如下面日期类(Date)的)_month
成员.
#include
using namespace std;
class Date
{
int _month;
private:
int _year;
public:
void SetYear(int year);
int GetYear();
};
void Date::SetYear(int year)
{
_year = year;
}
int Date::GetYear()
{
return _year;
}
int main()
{
Date date2;
// 通过公有成员函数访问私有成员变量
date2.SetYear(2021);
cout << date2.GetYear() << endl;
//直接访问私有成员变量不可行
//date2._year = 2022;
return 0;
}
结果:
2021
通过前两个的例子,它们的作用显而易见:使用private封装不想被外界访问的成员,使用public开放访问成员的接口,能提高成员的安全性.
一般的操作是:在私有区域定义数据,在公有区域定义函数,以便能获取数据.
这个关键字和上一个十分类似,区别在于被protected限定的成员除了不能在类的外部访问之外,还可以被类的子类访问.在学习继承的过程中我们会理解.
上面的例子提到了"封装",最开始的汽车的例子也体现了封装的思想.
封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全。数据封装引申出了另一个重要的 OOP 概念,即数据隐藏。
数据封装是一种把数据和操作数据的函数捆绑在一起的机制,数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制。
–来源于菜鸟教程
总的来说,封装就是将需要保护起来的数据被访问的权限降低,而开放能得到和修改部分数据的接口给用户,以达到最大限度保证数据安全的目的,也降低了用户的使用成本,提高效率.
封装是面向对象的三大特性之一,除此之外,面向对象的特性还有继承和多态.
作用域影响着编译器在何位置查找对象,即影响着搜索规则,也就是存储属性.
注意区分生命周期和作用域之间的区别.生命周期是对象或变量储存的(物理)位置,根据不同关键字的修饰,它们有可能在栈区,堆区,静态区或常量区.
#include
using namespace std;
class Date
{
int _month;
private:
int _year;
public:
void SetYear(int year);
int GetYear();
};
//需要指明函数所属的域
void Date::SetYear(int year)
{
_year = year;
}
int Date::GetYear()
{
return _year;
}
就如上面例子中在类外部定义的成员函数,必须指定函数所属的类区域.
一个类就像一个房子的图纸,类的实例化就是照着图纸造出来的毛坯房,通过各种接口,可以访问或修改成员变量,这就相当于给房子装修.换句话说,类就是一个模板,通过它能创造出任意个实例.
类的实例化上面已经出现了
#include
using namespace std;
class Person
{
public:
int age;
};
int main()
{
Person xiaoming;
xiaoming.age = 18;
cout << xiaoming.age << endl;
return 0;
}
结果:
18
区分声明和定义:
声明和定义的区别用上面图纸和房子的例子也好理解:声明就是图纸,不能住人;实例化的对象是房子,可以住人
一个经常被错误声明造成的错误:链接冲突
多个.cpp文件都包含同一个头文件(.h),这个头文件中有一个全局变量
//test.h
int a;
//test.cpp
#include "test.h"
//main.cpp
#include "test.h"
//报错
duplicate symbol '_a' in:
main.cpp.o
test.cpp.o
在编译后产生的.o文件中,每个变量都是有别名的,它们的名字都存放在符号表中.但是全局变量是存放在静态区的,两个.cpp文件访问的都是同一个变量a,这样是违反规则的,无法让变量进入符号表,也就找不到变量a.
修正方法:
类在结构体的基础上取长补短,储存方面除了普通的内置类型成员变量还有自定义的成员函数,如何才能在不同情况下达到利用最大化呢?
理论上实例化多个对象各自的成员变量和成员函数在物理上都是独立存在的,因为对象是类的实例化.通过打印地址可验证.
调用函数的操作,函数在编译链接时就已经被链接器通过函数名找到了(地址),而不是在运行时被找到.
这种办法也就只有缺点了,那就是占用空间,明明函数的功能是一样的,实例化一个对象就要多展开一次代码,降低了代码的复用性.下面通过打印地址来看编译器是否采取了这种方法.
#include
using namespace std;
class Date
{
int _month;
public:
int _year;
public:
void static Print();
};
void Date::Print()
{
cout << "Date::Print" << endl;
}
int main()
{
//实例化两个对象,初始化它们的成员
Date Date3;
Date Date4;
Date3._year = 2021;
Date4._year = 2022;
//打印一下成员变量和成员函数的地址
Date3.Print();
Date4.Print();
cout << &Date3._year << endl;
cout << &Date4._year << endl;
return 0;
}
结果:
Date::Print
Date::Print
0x16d713708
0x16d713700
由于C++不方便查看成员函数的地址,所以通过汇编查看两次调用成员函数的地址.
结果证明,成员变量确实是独立开来的,但是成员函数却是同一个
成员变量存放在类中,成员函数存放在其他地方,然后将它们的地址放在表中.这样只需存一份表,就可直接访问地址,使用了函数指针.特殊情况下会使用此方法,比如多态.
实际上,这就是编译器采取储存对象成员的方法(除特殊情况外).
如何理解公共代码区:公共代码区就是字面意思,编译器有时候会将频繁使用的函数代码(二进制)存放在这个区域,只要需要就在这里面取即可,无需再生成一次代码,提高复用性.
下面验证结论,实例化一个对象,将它指向nullptr,然后通过它访问成员函数,如果运行超过,则说明编译器采用的是这个方法–成员函数不存放在对象中(物理)
#include
using namespace std;
class Date
{
int _month;
public:
int _year;
public:
void static Print();
};
void Date::Print()
{
cout << "Date::Print" << endl;
}
int main()
{
Date* date1;
date1 = nullptr;
(*date1).Print();
return 0;
}
结果:
Date::Print
结果证明了编译器采取的是第三种方法.
类作为C++的新特性,继承了C的结构体.结构体有计算大小的规则,类也不例外.
#include
using namespace std;
//类既有成员变量也有成员函数
class A1
{
public:
void f1(){};
private:
int _a;
};
//类有成员函数
class A2
{
public:
void f2(){};
};
//空类
class A3
{};
int main()
{
cout << sizeof(A1) << endl;
cout << sizeof(A2) << endl;
cout << sizeof(A3) << endl;
return 0;
}
结果:
4
1
1
通过上面的结果结合对象的储存方式看,类的大小只与类成员变量的大小有关(除了平台),和成员函数无关.原因自然是成员函数并不储存在类中,当然类的大小不会把成员函数算进去.
值得注意的是空类的大小是1.通过查阅资料,我总结了以下几点:
结构体或类的数据储存在内存中是在函数栈帧被开辟之后进行的,内存对齐能够合理高效地利用内存.
在C++中,this指针是所有成员的隐含参数,每个对象都能通过this访问自己的地址.因此它常被用来在成员函数中特指对象中的成员变量.
show you the code.
为了阅读和举例的方便,将成员函数定义在类的内部
#include
using namespace std;
class Date
{
public:
void SetFunc(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void GetFunc()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date date;
date.SetFunc(2022, 8, 2);
date.GetFunc();
return 0;
}
结果:
2022/8/2
这段代码定义了Date类,有年月日三个私有成员变量,两个公有成员函数.Set函数是给对象的成员变量赋值,Get函数是获取成员变量(通过打印代替).
在Set函数中,看起来是year赋值给year,这是好像在做无用功.
实际上在编译器眼中它们是这样的:
class Date
{
public:
void SetFunc(int year, int month, int day)
{
this->year = year;
this->month = month;
this->day = day;
}
void GetFunc()
{
cout << this->year << "/" << this->month << "/" << this->day << endl;
}
private:
int year;
int month;
int day;
};
this是一个常量指针classname* const this
,它被const修饰,它存放的地址(它的值)是被允许修改的.当我们调用某个对象的成员函数时,实际上是由this替对象调用的成员函数.对于我们而言,this是隐式定义的,它隐式地指向对象,保存着对象的地址.
由一段伪代码理解this保存的对象的地址,假如类是上面的Date:
Date date;
date.GetFunc();
实际上通过对象调用Get函数是将对象date的地址传给this指针,由this指针找到对象的成员函数.
Date::GetFunc(&date)
一个对象也可以被认为是一个类的成员,因此在类域传入成员的地址可以找到该对象.上面的等价操作就是在编译时,编译器会在成员函数中成员变量前都加上this->
.通过对象调用成员函数,**第一个形参(隐藏)**会传入对象的地址.
- 区分运行崩溃和编译报错.编译阶段是检查语法;运行崩溃是在没有编译错误的基础上运行后产生的逻辑错误,如空指针,越界等.
- this被const修饰,它不能被修改,但是它指向的内容可以修改.复习const
- 传递指针通过寄存器或其他,取决于编译器(IDE).
复习const关键字修饰指针
问题:
//a.编译报错 b.运行崩溃 c.正常运行
#include
using namespace std;
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
结果:
Print()
选c
原因是成员函数并不储存在类中.即使p是空指针,然而并未对空指针进行解引用操作,因此不会编译报错,更不会运行崩溃.
//a.编译报错 b.运行崩溃 c.正常运行
#include
using namespace std;
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
结果:
b.运行崩溃,因为空指针问题
请注意函数中是直接打印_a.通过p调用成员函数,实际上是对指针解引用,编译时发现不了,运行时崩溃.
在使用类需要传参的成员函数时,常常与缺省参数搭配使用,这样让传参个数更灵活.
例如:
#include
using namespace std;
class Date
{
public:
void SetFunc(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void GetFunc()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date date;
//还未调用Set函数就直接打印
date.GetFunc();
//不传参
date.SetFunc();
date.GetFunc();
//传参
date.SetFunc(2002);
date.GetFunc();
date.SetFunc(2003,2);
date.GetFunc();
date.SetFunc(2004,3,2);
date.GetFunc();
return 0;
}
结果:
1/1/1
2000/1/1
2002/1/1
2003/2/1
2004/3/2
具有初始化成员变量值的功能的函数,常常使用缺省参数以增加传参方式的数量.另外在定义类的成员变量的同时也是可以声明它的值的(注意区分声明和定义),通过第一次打印就可以知道成员变量声明的值就是对象实例化后成员变量的默认值.
C++和C最大的不同在于其面向对象的思想,就拿之前用C实现数据结构栈(Stack)来讲:
C++的优越性远不如此,在后续的学习过程中我们将领略它的魅力.
8/3/2022