目录
(一)面向对象思想:
(二)类:
(三)创建一个日期类
(四)编译器相关知识点介绍
(五)编译器视角:
如何初始化,变量,如何调用的知识。作为写代码的人,往往对于一些非语法错误百思不得其解。这背后往往的是与编译器流程有关。知己知彼,才能百战不殆,也能少些Bug!
(六)由于视角差异:启发性的思考几个问题:
(1) 结合之前知识分析初始化构造函数的最优方案
(2)何时需要一个析构函数?
(3)实现一个重载函数的细节分析
(4)比较重载运算符+=与+
(5)传参和传返回值的本质分析
(6)何时使用拷贝构造和赋值重载?
(7)对于封装的理解和打破封装
(七)总结:
结语 : 如有错误,请多多指正!!
(一)面向对象思想:
C++是基于面向对象的,它不仅包含面向过程还支持多种方式编程。与C语言对比,C++提出的面向对象是统一了变量与函数(即对象这个概念)。但编程思想不会止步于面向对象。一定有超越对象的概念的去继续抽象的统一。
直观来看:C语言中数据与方法是分离的,而在C++中数据和方法是放在一起的。C语言的面向过程编程关注的是过程,分析求解出问题的步骤,通过函数调用来逐步解决问题。而C++面向对象关注的是对象,将一个事件拆分出不同的对象,靠对象间的交互来解决问题。
(二)类:
C++中类这一概念的原型来自C语言中的结构体。但也只是局限在描述“不同类型数据的集合”。在C++中结构体升级成类。
在C++中,类的地位很特殊。他的成员函数存储方式不同,决定了调用方式和使用方式的不同。他的成员变量的关系上的复杂性,决定了“类的权限”也是复杂的。类的出现,也改变了语法的设计。
(三)创建一个日期类
要求大概如下:首先考虑一个类体的基本需求:1->成员变量,2->构造系列(即成员变量的初始化数据维护)全缺省构造函数+定义域限制。3->其次才是:再思考添加功能丰富的成员函数。
我们可以在脑海中建立类的基本模块:
类的权限(访问限定),类的维护(构造和析构),类的自定义(拷贝构造和赋值构造和重载),类的结构(成员变量与成员函数),类的特性(指针访问),类的封装(友员,静态)。
(四)编译器相关知识点介绍
编译器与语法的设计是协同发展的。在C++中,我们可以看到这两者相互解释。
1.调用函数方面:类中的成员函数,不同于普通的函数,成员函数服务于类。为了避免反复调用重复的函数,编译器将它存在于公共代码段中。为了区别不同对象的调用,C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参 数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该 指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
3.数据类型问题。对于内置类型和自定义类型编译器在运算,初始化等方面会提供不同的处理方式。自定义类型:是类各种类型的一个统称,内置类型,类外的类型。这两者的区别在于,是否属于类还是独立存在。对于自定义类型,编译器会提供默认的无参构造系列(无参构造函数,析构函数,赋值),如果我们没有去定义,那么编译器就会自己调用这些默认的无参构造系列。写了,就用我们自己的写的自定义构造。但对于内置类型,编译器没有提供这些操作。
4.临时变量问题。一般来讲,我们要避免临时变量的产生,临时变量意味着多余的“拷贝”。而编译器产生临时变量一般来说都是隐式的,但往往是会带来一些不易发现的bug。本质上来讲,是因为c++编译器的一个关于语义的限制。产生临时变量的情景:类型转换的时候会产生临时变量(比如int->double)。参数为const类型(临时变量具有常性,不可被修改。若传入了非const类型,编译器会默认为这是可修改的,但临时变量存在于栈中随时会被释放,可能会引起非法访问)。传值传返回值,运算中的中间值等。但编译器有优化:一般比较新的编译器,会把中间对象优化掉,两步连续的赋值构造会被优化成一步拷贝构造。
Date f()
{
Date ret=*this;
ret+=day;
return ret;//此处Date会产生一个临时的拷贝对象a
}
Date X=f();//a赋值拷贝给X
//编译器会把这优化成一个拷贝构造。
5.返回值问题。对于编译器来说,返回值并不是直接返回函数的,而是经过了拷贝。是因为函数的调用会开辟栈帧,函数调用完毕栈帧也随之销毁。所以返回值作为上一个栈帧中的临时变量出了函数作用域是不能存在了的,所以为了避免内存的非法访问或访问不到,要复制一份作为返回值返回。对于内置类型来说,因为数据类型比较单一,所以一般通过拷贝就可完成。但对于自定义类型,数据类型复杂多样,单纯的拷贝没办法提供准确的拷贝,于是需要调用赋值重载函数(二者均已存在)和拷贝构造函数(一个存在赋给一个不存在的)辅助来完成拷贝。但自定义类型有特别之处,有一个*this变量,出了作用域一直存在。返回*this(即引用)可以了拷贝的消耗。这种设计很适合“引用做返回值的情形”。
6.运算符重载的问题。对于内置类型来说,可以实现编译器附带的"+, -,*,/,>"等。但是对于自定义类型来说,编译器不提供运算符重载函数,需要自己定义去写。然而,事实上这并不是个很繁重的写代码工作。从另一个角度看,基本运算符本质上都是大同小异的,“等于与不等于”,“大于等于 与 小于”,可以巧妙的通过复用代码来减少工作量。又比如输入输出函数我们目前也可以通过,点出属性自定义打印格式。我们往往能找到它们之间的联系。这时,我们也能学到一些编译器的思维。例如前置++和后置++,当我们写重载函数时。他们因为参数的个数,顺序,类型都一致编译器从而无法进行区分。于是编译器为前置++人为的添加了一个参数,从而构成了重载。这一特殊处理只是为了编译。但足见编译器设计的灵活性。
7.初始化方面:
对于编译器来说,要先对成员变量有初始化,然后在对象实例化的时候对象还要有一次初始化,第一是给定义,第二次是赋值。抽象意义上是初始化参数列表是符合编译器工作机制的。自定义类型会用初始化列表初始化,内置类型不处理。因为初始化的特殊性,所以如果没有显式调用构造函数,编译器会自动调用默认的无参构造函数完成初始化。一般来讲,会写成参数列表的形式初始化。
8.就近一致原则。编译器在搜索时遵循局部变量原则。首先会在当前域内搜索,如果要是搜到了,就不会再去全局函数中搜索。(所以我们的成员变量要与传的形参命名有所区分,防止漏掉搜索)编译器通过命名和this*识别对象。
(五)编译器视角:
如何初始化,变量,如何调用的知识。作为写代码的人,往往对于一些非语法错误百思不得其解。这背后往往的是与编译器流程有关。知己知彼,才能百战不殆,也能少些Bug!
(六)由于视角差异:启发性的思考几个问题:
学习实现一个重载函数及细节分析?
何时需要一个析构函数?
结合之前所学如何分析初始化函数的最优方案?
何时需要拷贝构造函数和赋值重载函数?
对于封装的再理解和打破封装。
比较运算符重载+=与+。
传参与传返回值的本质分析?
(六)由于视角差异:启发性的思考几个问题:
学习实现一个重载函数及细节分析?
何时需要一个析构函数?
结合之前所学如何分析初始化函数的最优方案?
何时需要拷贝构造函数和赋值重载函数?
对于封装的再理解和打破封装。
比较运算符重载+=与+。
传参与传返回值的本质分析?
何时需要一个析构函数?
结合之前所学如何分析初始化函数的最优方案?
何时需要拷贝构造函数和赋值重载函数?
对于封装的再理解和打破封装。
比较运算符重载+=与+。
传参与传返回值的本质分析?
何时需要拷贝构造函数和赋值重载函数?
对于封装的再理解和打破封装。
比较运算符重载+=与+。
传参与传返回值的本质分析?
比较运算符重载+=与+。
传参与传返回值的本质分析?
以上问题均从两个角度分析。
(1) 结合之前知识分析初始化构造函数的最优方案
1.根据前文,对于内置类型编译器会生成【构造系列】,但对于函数的初始化还承担着更重要的作用。所以我们一般要写自定义的有参构造函数。
2.(1)如果不写,编译器会生成默认的无参构造函数,处理成随机值(2)如果写了无参默认设为缺省值。(3)全缺省/半缺省。
3.系统默认的只是处理成随机值(依然不规范),那和不处理没啥区别。写成无参的不如写成有参的,后面可以直接调用初始化函数初始了。写成半缺省的不如写成全缺省的。一来有了规范的缺省值,二来无参和有参都适用,这是C98下的最优方案。随着时代的发展,C++11,为构造函数的复杂机制打了补丁,减少了工作量。
4.最优方案:对于内置类型,采用定义处写缺省值的办法。(其实在这里,自定义类型也可以写缺省值处理)对于自定义类型,为了后面传参方便,我们最好写成全缺省的构造函数方式(半缺省从左到右传也许更实用于某种场景)。
//.h文件
{public:
R(int name=99,int height=153)
{ _name = name;
_height = height;
}
void print(){
cout<< _height<<" "<<_house;
}
private:
int _house = 999;//这里的定义的是缺省值,并非初始化。
int _name;int _height;
};
int main()
{
R r1;
r1.print();
}
(2)何时需要一个析构函数?
1.析构函数的使用一般是特殊类型的类。与构造函数功能相反,析构函数不是完成对对象的销毁,局部对象的销毁是由编译器完成的。而对象在销毁时,会自动调用析构函数,完成类的资源的清理工作。
typedef int DataType;
class SeqList
{
public :
SeqList (int capacity = 10)
{
_pData = (DataType*)malloc(capacity * sizeof(DataType));
assert(_pData);
_size = 0;
_capacity = capacity;}
~SeqList()
{
if (_pData)
{
free(_pData ); // 释放堆上的空间
_pData = NULL; // 将指针置为空
_capacity = 0;
_size = 0;
}
}
private :
int* _pData ;
size_t _size;
size_t _capacity;
};
2.其特征如下: 1. 析构函数名是在类名前加上字符 ~。 2. 无参数无返回值。 3. 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。 4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
3.只处理自定义成员。两个栈实现队列,入栈,出栈。对于myQueue。
4.与构造函数的关系:相似且互逆。(1)相似:都是编译器自动调用,如果未显示定义,就会调用默认生成的 (2)互逆:构造函数初始化在初始化列表阶段自动调用。析构时作用顺序与初始化顺序相反。
(3)实现一个重载函数的细节分析
1.背景:由于编译器对自定义类型函数不提供运算符重载所以我们需要自己定义,这里">"的实现远比“2>1”复杂得多。
2.需求分析:首先定性,什么类型?显然这属于是逻辑关系判断,应该用bool类型。再者,函数部分我们要关注传参和返回值问题,这个函数作为类体内的函数,实现时需要this指针作为第一个形参(具体原因上边已经说过),“>"是双目运算符,需要操作数是两个,this指针代指左操作数,那么我们需要传入一个右操作数,根据经验采用传引用传参,对于返回值bool类型的返回值是0或1。然后再具体到实现逻辑,那么应该怎么模拟呢?就是解释清对于这个类,“>”的具体含义是什么,通过if等语句......这就是之前C语言的内容。
既然是为类类型而生,那么就得写在类体内。
语法格式:bool operator>(const date&d1)对于传参:第一个参数是this*:指代的是调用此函数的对象,作为左值,第二参数为右值进行判断。
可以理解为----->d1>d2.
//创建一个时间类,类体里添加这个函数,功能:比较两个日期的先后(日期靠后认为小)
bool operator<(const date& x1)
{
if ((_year < x1._year) || (_year == x1._year && _month < x1._month) || (_year == x1._year && _month == x1._month && _day < x1._day))
return true;
else
return false;
}
3.对于我们写出的自定义要进行一定的检验
int main()
{
date x1(1988, 12, 2);//实例化同时,初始化构造函数
date x2(1978, 12, 2);
x1 < x2;//另一种写法 x1.operator<(x2);
cout << (x1 < x2) << endl;//输出结果,加括号因为<<运算符优先级高于<
}
这里的_year是指this* _year 也可以显示写出来this->_year
4.赋值重载函数的原型:
对于内置类型会完成值拷贝,对于自定义类型,会调用它的赋值重载函数。
(4)比较重载运算符+=与+
1.这两个符号分别是,加到与加上。+=表示结果,运算的对象还是最初的那个即*this,+表示操作数的变化量,原来的*this未发生变化,所以需要拿一个值接收*this+变化量。2.代码如下3.这里传达了一个优化思想“减少拷贝”
//运算符重载实现+=(日期+天数)
Date& operator+=(int day)
{
if (day < 0)
{
return *this -= day;
} //代码复用,逻辑:数学思维
_day += day;
while (_day > GetMonthDay(_year, _month))
{
++_month;
_day -= GetMonthDay(_year, _month);
if (_month == 13)
{
++_year;;
_month = 1;
}
}
return *this;
}
3.首先对于一个成员函数有几个角度把握:传参,传返回值。再进一步分析,分析对象所对应的类型。具体分析参见下一条。
(5)传参和传返回值的本质分析
1.传参一共有三种:传值/传址/传引用。绝大多数的情况我们纠结于传值还是传引用。一般来讲:&--->const--->这样考虑。对于类和对象而言:因为成员函数隐含一个终身跟随的指针,出了成员函数域生命还在。所以不用考虑非法访问。尽量用引用。更多的时候,我们更多思考“拷贝”的问题。
另外,还要根据对象的需求进行考虑,改变自身,还是不改变自身。其实这里要说的是共性。
我们可以拷贝一个*this的临时构造对象进行运算,也可以用*this本体运算。
根据编译器相关知识,拷贝会产生一个临时变量的情况,而临时变量发生改变的情况要特别注意。
//+=复用+ :没加到*this本体上返回*this *this+=day; Date ret=*this; return *this; //+复用+= :加到*this本体上,返回*this+(ret) Date ret=*this; ret+=day; return ret;
(6)何时使用拷贝构造和赋值重载?
(1)拷贝构造作用效果编译器默认的就是把一个变量“按字节序拷贝”给另一个变量。这就是传值的本质。将实参复制一份给形参,然后代入函数进行运算。
(2)对于拷贝构造和赋值重载相同之处就是进行赋值操作。这种字节序的拷贝称之为浅拷贝,还有深拷贝。
特点:1.浅拷贝指向同一块空间,所以会析构两次2.其中一个修改值会影响到另一个
我们再来看深拷贝:
总结: 如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情 况都是按照深拷贝方式提供。
(3)拷贝构造函数和运算符重载有两个不同之处:1.拷贝构造函数产生的新的类对象,而运算符重载不能。2.赋值重载在初始化之前不用检验源对象是否和新建对象相同。另外赋值运算符中如果有内存分配要先把内存释放掉。
(3)当类成员中有指针变量时一定要小心。当多个指针指向同一段内存时,某个指针释放这段内存可能会导致其他指针的非法操作。所以在释放前一定要确保其他指针不再使用这段空间。
(7)对于封装的理解和打破封装
1.封装的本质是一种管理。内存存储分为:栈,堆,静态区(数据段),常量区(代码段)。不同的存储区域有自己的作用域。这意味着不同的生命周期和权限。他们承担着自己的角色。
2.类的权限:“成员函数”是类内成员对外部的接口。
3.决定功能不同的因素是域的作用范围不同,决定域不同的因素是存储位置和方式不同。除了2.中显然的权限设置,我们还可以通过改变存储位置,来改变作用域。
4.如何不破坏封装去访问私有成员变量?===>抽象下问题:如何建立起成员变量与外部之间的练习?
(1)法一:static:静态修饰法
静态数据成员是类的一部分,为类的所有实例共享(静态区);非静态数据成员,类的每个实例都有一份拷贝(动态区)。静态数据成员是类的一部分,在产生任何实例之前已经存在,通过类名::静态成员变量名访问。所以都要先初始化。
类的静态成员(数据成员和函数成员)为类本身所有,在类加载的时候就会分配内存,可以通过类名直接访问;非静态成员(数据成员和函数成员)属于类的实例所有,所以只有在创建类的实例的时候才会分配内存,并通过实例去访问。作用域依然是:局部作用域类域
//静态成员函数获取私有成员变量
class A
{
static int _account;//类中声明
}
int A::_account=0;//类外初始化
//非静态成员函数获取类的私有成员变量
A::getcount()
{
//获取_account;
}
A().getcount();
a2.getcount();
//通过实例的对象去获取
(2)法二 friend:友元函数
1.友员函数可以访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明时加friend关键字。
2.在类B中声明 friend class A :A可以访问B,不代表着A可以访问B。友元类不具有交换性和传递性。
3.A是B的外部类,A是B天生的友员。
(七)总结:
1.本节奠定了面向对象的基础。类和对象的这些模块也是研究继承和多态的基本模块。
要结合编译器的特点分析语法的底层逻辑。才能充分发挥语法的用处。这一篇以编译器逻辑分析为主,下一篇侧重用语法的角度来分析。



