目录
1、成员变量的秘密(field, parameters, local variables):
3、构造和析构(Constructor & Destructor):
8、函数重载和缺省参数(Function overloading & Default arguments):
引言:
本学习笔记是在学习 浙江大学 翁恺 老师的《面向对象程序设计-C++》课程过程中整理的。适用于具备C语言编程基础,对微型计算机原理有简单了解;有意向学习面向对象程序设计,并入门C++的小伙伴~
本学习笔记分为上半部分和下半部分。上半部分对应教学视频的前20个课时;下半部分对应第21~41课时。
翁恺老师在教学过程中使用的是Vi文本编辑器以及GCC编译器。如果目前您的计算机上还没有合适的编辑器或C++编译器,请先参考:READMEhttps://blog.csdn.net/YMGogre/article/details/127224211
0、程序设计哲学:
- 尽量把我们的代码建筑在已有代码的基础上(避免Code Duplication),如果你的程序有很多一模一样的代码,显然出错了修改起来会很麻烦。
1、成员变量的秘密(field, parameters, local variables):
简单介绍了C++的几种变量之间的关系以及引出C++的this关键字。更本质的是从面向过程的角度审视面向对象:Bjame Sgoustrup在1979年刚开始研发C++的时候,他的手段仅仅只有C,他是怎么实现用C语言来表达C++的诸多特性呢?或者说,要怎样把C++的特性翻译回C语言实现呢?
- 这三种变量都能够存储与他们类型定义相符的一个数值。
- 参数(parameters)和本地变量(local variables)是完全相同的东西:
- 它们的存储属性都是本地存储(进入函数之前它们都不存在,进入函数之后他们才会存在);两种变量都会放在名为“堆栈(stack)”的地方,但是在堆栈中的具体位置还是有所不同的。
- 形式参数和本地变量仅在构造函数或方法执行期间持续存在。它们的生存期仅相当于一次调用,因此在调用之间会丢失它们的值。因此,它们担当临时存储而不是永久存储。
- 形式参数在构造函数或方法的头部定义。它们从外部接收其值,由来自构造函数或方法调用部分的实际参数值初始化。
- 组成类的两种要素:成员变量和成员函数
- 字段(field)是在构造函数和方法外定义的,是类的成员变量。
- 全局变量的声明(添加 extern 修饰),只是在告诉编译器“我知道有一个全局变量,但是我不知道它在哪”;而字段就是这样的,它是一种成员变量,成员变量是写在类的声明里面的。
- 字段用于存储贯穿一个对象整个生存期的数据。同样的,它们维持着一个对象的当前状态。它们与它们的对象有相同的生命周期。
- 字段拥有类的范围:他们的可访问性贯穿整个类,所以它们能够被它们所在的那个类中的任何构造函数或者方法使用。
- 只要字段被定义为私有(private),就不能从定义类之外的任何地方访问它们。
C++中类定义的形式如下:
class 类名
{
访问范围说明符:
成员变量1;
成员变量2;
...
成员函数1声明;
成员函数1定义;
...
访问范围说明符:
更多成员变量;
更多成员函数声明或定义;
...
};
成员函数1类型 类名::成员函数1(参数列表)
{
}
成员变量是在类中声明的变量;同样地,成员函数是指在类中声明的函数。如上类的形式所示,成员函数可以在类中定义,也可以在类外定义。在类外定义的函数需要用类名和作用域运算符(类名::
)限定函数所属的类。
而成员变量的定义在实例化对象,给变量分配内存的时候才会发生。
此外关于类的声明和定义可参考如下文章。简而言之,定义类只是在定义一个自己的数据类型;这与我们平时理解的声明和定义基本类型不同:类的声明和定义都不会分配内存,只有在实例化对象的时候才会分配内存。
1.1、C/C++中.h与.cpp文件:
该小节转载自《C/C++:头文件与cpp文件的声明/定义》
- 头文件(.h文件)
- 一般来说,头文件仅仅用于声明,相应的定义要放在对应的cpp文件中。声明的内容一般可以是:
1、类定义体;(这里可参考"头文件为什么只声明不定义,而类定义又可以放在头文件中" 以及"关于C++的变量和类的声明和定义")
2、类中的成员函数;
3、类外的函数(free函数);
4、类外的变量;
5、类型;
一个文件(比如main.cpp)包含(#include)了一个头文件(比如item.h),就相当于声明了Item.h中声明的所有内容。
- 但是const常量、inline函数、static函数都可以在头文件中定义(如果是初次学习C++,这点目前仅作了解,之后会慢慢学到)。
- .cpp文件
- .cpp文件用于定义,定义的内容一般可以是:
1、类的成员函数;
2、类的静态成员变量;
3、类外的函数(free函数);
4、类外的变量;
- 各种内容定义总结(总结的内容之后都会展开讲,初学C++的小伙伴目前可仅作了解,有个印象就行):
- 类:类一般只在头文件中定义,在cpp中实现其成员函数的定义;
- 类中的成员包括:普通成员函数、static成员函数、普通成员变量、static成员变量、const成员变量、static const成员变量等;
- 普通成员函数——在类内部声明;可以在“类内部/头文件中的类外部”定义(均看作inline);也可以放在cpp中定义(非inline)
- (这点讲到《内联函数(Inline functions)》一章会展开);
- static成员函数——类内部声明;可以在“类内部/cpp中”定义,不能再“头文件中的类外部”定义。在类外部定义的时候要去掉static关键字,因为类里面的static表示该成员属于类,而文件中的static表示文件作用域,这是完全两回事
- (这点在下半部分学习笔记的《静态成员(static member)》小节会展开);
- 普通成员变量——类内部声明和定义;只能在构造函数的初始化列表中初始化,用户可以不进行初始化(编译器将默认构造)
- (这点讲到《构造和析构(Constructor & Destructor)》一章会展开讲);
- static成员变量——类内部声明;只能在cpp中的各方法(函数)外部定义(且不能加static关键词,原因同static成员函数),定义时可以不进行初始化,这时默认为0(也可以不定义,但若使用到了该成员变量,则必须定义,否则连接出错)
- (这点在下半部分学习笔记的《静态成员(static member)》小节会展开);
- const成员变量——类内部声明;只能在构造函数的初始化列表中初始化,而且用户必须自行初始化
- (这点讲到《Const》一章会展开);
- static const成员变量——基本同static;特别之处在于,static const成员变量是唯一可以在定义的时候(即类内部)直接初始化的类成员变量;注:static和static const不能在构造函数初始化列表中初始化,因为static关键字表明,它属于类,而不是属于对象;
2、This关键字的出现:
- 类是抽象的、是虚的,更像是一种概念,不是实体,它不拥有它声明的任何一个变量;只有类的对象才是实体,才拥有那些变量。类和对象间变量的关系有点类似于C语言中结构体与结构体变量之间变量的关系。但是定义在类中的函数是属于类的,而不属于类的任何一个对象。总结为一句话就是:类拥有函数而不拥有变量;对象拥有变量而不拥有函数。
- 由上述关系可知:假设一个类Class的成员函数f( )要对字段进行操作,C++是如何知道Class的不同对象调用f( )时是谁在调用f( ),以便对各自的字段进行操作的呢?
- 在C语言中,为了实现上述操作通常需要传递指针给函数f( ),在C++中,这个指针通过“this”关键字实现。
- this是所有的成员函数都拥有的隐藏参数。它的类型是这个函数所属的那个类的对象的指针。
-
Class a; a.f(); ->(可以看作是) Class::f(&a); //C++中“::”是一种解析符,它的意思可以理解为“的”,如上是“Class的f(&a)”
- 在成员函数内部,可以使用this作为指向调用函数的变量的指针。
- “this”是所有类成员函数的自然局部变量,您无法定义,但可以直接使用它。
- 由上述关系可知:假设一个类Class的成员函数f( )要对字段进行操作,C++是如何知道Class的不同对象调用f( )时是谁在调用f( ),以便对各自的字段进行操作的呢?
3、构造和析构(Constructor & Destructor):
考虑到效率,C++没有规范地约束在生成对象时必须对其初始化(其他一些OOP语言比如Java存在此约束)。如果程序员自己写 init() 函数则需要依赖于他的自觉性(即有没有在生成对象后立刻调用init(),否则就会出问题),所以我们需要一种机制来确保生成对象后一定会被初始化,这便是constructor构造函数的由来。
- 构造函数名字必须与类名相同(包括大小写);
- 构造函数没有返回类型;
- 构造函数会在类的对象被创建时自动被调用;(所以一般构造函数用于初始化操作)
- 构造函数可以有参数,对应在创建对象时也需要传个参数;
Tree(int i) {...} //构造函数
Tree t(12); //创建对象
- 只要不带参数的构造函数都称为“default constructor”;而编译器给你的的构造函数称为“auto default constructor”,当你定义了带参构造函数却没有在生成对象时正确调用,编译器会去寻找default constructor调用;
#include <iostream> using namespace std; class A { private: int i; public: A(int i) { this->i = i; } }; int main() { A test[2] = { A(1) }; //实例化对象数组,但仅第一个数组对象调用了构造函数 //正确可以改为A test[2] = { A(1), A(2) }; }
运行结果:
- 析构函数即在构造函数名字前加一个波浪号(~)
-
class Y { public: ~Y(); };
- 析构函数会在对象要被“消灭”掉的时候自动被调用;(比如在一个大括号内创建栈对象的话,离开大括号范围的时候对象就会被“消灭”掉)
- 根据析构函数的特性,我们一般会用析构函数来释放掉对象生存期间申请的资源,保证这些资源不会随着对象被“消灭”掉之后一并被带到“棺材”里面去;
- 析构函数也没有返回类型,而且不能有参数;
3.1、初始化列表(Initializer list):
- 除了使用构造函数来做初始化,C++还提供了另一种初始化方法:初始化列表;
- 在构造函数的圆括号后面加上冒号,冒号后面跟上成员变量的名字,最后用括号给出初始值;
class Point { private: const float x, y; Point(float xa = 0.0, float ya = 0.0) : y(ya), x(xa){ ... } };
- 初始化列表可以初始化任何类型的数据;
- 初始化列表会早于构造函数被执行;
- 严格来说,初始化列表做的工作才是初始化;而构造函数做的工作可以称作为“赋值”,构造函数会做两件事:1. 初始化(这个时候你没有明确告诉编译器用什么内容来初始化);2. 赋值;
4、new & delete(动态地制造对象):
在C语言中,我们通过malloc与free动态地申请和释放内存;在C++,则是用两个新的运算符关键字new和delete来制造和收回一个对象的。如果new一个变量,则只需做一件事情:分配一个变量的空间;但是如果new一个对象,则new会做两件事情:1. 分配一个对象的空间;2. 调用构造函数;当然最后作为一个运算符会返回分配给对象的地址。而delete做的事情与free类似,你给它一个地址,然后它delete掉。对于delete一个对象:1. 调用析构函数;2. 收回内存空间。
- new出来的东西会放在堆里面;
- 动态申请的内存完全由开发者自行负责管理,开发者对堆对象的生存周期具有完全的支配权(在何时申请内存,分配多少内存,并在何时释放该内存);
- 由上一条可知:在堆中的对象不会自动被消灭,内存不会自动回收,new出来的对象在程序运行过程中会一直占用内存空间,直到开发者在代码中主动delete掉它或者程序进程整体退出;
- 程序进程退出时的内存回收是系统级的,系统会回收分配给该进程的所有内存。但那个时候系统就并不关心你程序里面是如何使用它的了;也就是说系统仅仅是回收内存,不会再帮你调用析构函数了;
- 我们知道new作为一个运算符会返回分配给对象的地址。如果我们把new返回的地址交给局部指针变量,根据第一章成员变量的秘密我们知道局部变量担任临时存储,那么局部指针变量一但离开局部空间后被销毁,我们就再也无法访问到new申请的内存了。下面是一个代码例:
#include <iostream>
using namespace std;
class A
{
public:
A() { cout << "A::A()" << endl; }
~A() { cout << "A::~A()" << endl; }
void print(string str) { cout << str << endl; }
};
int main() {
{
A* p = new A(); //把new返回的地址交给局部指针变量
}
p->print("Hello World"); //此时指针变量p已经被销毁,但new申请的内存还没有回收
delete p;
return 0;
}
会给出如下错误:
- 不要用delete去释放不是new分配出来的空间;
- 不要连续两次用delete释放同一块空间;
- 如果用new []分配了一块数组,请用delete [];同样地如果用new分配了单个实体,请用delete;
- 如果new []了之后用delete释放的话,仅会调用delete指针指向的对象的析构函数,虽然同样回收所有空间,但是会报错。(例如下方贴的代码)
- delete一个空指针是安全的;
- new出来的对象在使用完毕后不delete是非常危险的!对于长时间运行的程序很容易造成内存泄漏;
#include <iostream>
using namespace std;
class A {
private:
int i;
public:
A() { i = 0; cout << "A::A()" << endl; } //构造函数
~A() { cout << "A::~A(),i=" << i << endl; } //析构函数
void set(int i) { this->i = i; }
//根据就近原则,成员变量i会被参数i屏蔽,需要加this指针表示“调用这个函数的对象的i”
};
int main()
{
A* p = new A[10];
for( int i=0; i<10; i++)
{
p[i].set(i);
}
delete p;
//尝试用delete回收new []分配的空间,若要正确运行请改为“delete[] p;”
return 0;
}
运行结果:
5、访问限制(Setting limits):
在OOP理论阶段学习过:对象应该是被封装起来的受保护的,对象里面的数据是不被别人直接访问的。别人能访问的只有你的函数,可以通过你的函数要求你做事情;但是这个函数具体怎么做?会对你的数据产生什么样的影响?是由你的代码决定的。
所以我们需要一种机制,使得使用你的类的人不会随心所欲地访问内部的东西;同时设计类的人可以去修改内部的东西而不至于影响到使用者。对C++来说,所有的成员可以有三种访问属性:public、private以及protected。
- public没什么好说的,任何人都能访问;
- private只有自己(这个类中的成员函数)能访问,private可以修饰变量与函数;
- 注意private私有性是对类来说的,而不是对象!同一个类的不同对象之间是可以互相访问私有的成员变量的。(此外,C++对private权限的限制仅仅存在于编译时刻,到了运行时刻就没人管这件事了,原因是C++的OOP特性只在源代码层面体现,编译完后生成的.o文件同C语言、汇编语言、Pascal生成的.o文件是一模一样的。所以只要有办法过了编译那一关,剩下的事情就可以为所欲为了)
-
#include <iostream> using namespace std; class A { private: int i; public: A() { i = 0; cout << "A::A()" << endl; } //构造函数 ~A() { cout << "A::~A(),i=" << i << endl; } //析构函数 void set(int i) { this->i = i; cout << "this->i=" << this->i << endl; } void g(A* q) { cout << "A::g(),q->i=" << q->i << endl; } }; int main() { A a; a.set(50); A b; b.set(100); a.g(&b); //尝试用a中的成员函数g(A* q)访问b中的私有成员变量i; return 0; }
运行结果如下:
- C++还有个破坏OOP原则的东西叫做“friends”:你可以声明别人(可以是别的类,可以是别的不属于任何类的free函数,也可以是别的类里的某个函数)是你的friend(朋友),一旦声明过后,他就可以访问你的private的东西了;
- 但是不能是你声明你是别人的朋友,然后去访问别人的私有的东西。就像啊我声明:“我是小明的朋友,所以我可以用他的钱”,这是不行的,不是这么玩的。是由类自己决定谁可以访问自己的成员的。
/// ///演示代码,无法运行 /// struct X; //前向声明 struct Y { void f(X*); }; struct X { private: int i; public: void initialize(); friend void g(X*, int); //free函数是friend friend void Y::f(X*); //结构体成员函数是friend friend struct Z; //整个结构体是friend friend void h(); }; void X::initialize() { i = 0; } void g(X* x, int i) { x->i = i; } void Y::f(X* x) { x->i = 47; } struct Z { private: int j; };
(同样的friend的授权是在编译时刻检查的)
- 但是不能是你声明你是别人的朋友,然后去访问别人的私有的东西。就像啊我声明:“我是小明的朋友,所以我可以用他的钱”,这是不行的,不是这么玩的。是由类自己决定谁可以访问自己的成员的。
- protected表示只有这个类自己以及它的“子子孙孙”(子类以及再往下的这些)可以访问;
- 在设计中,我们一般按下面的方式规划所有东西的访问限制:
- 所有的数据(一般指成员变量)都是private的,外界及子类都不能直接访问;
- 提供给所有人(包括外界和子类)使用的东西是public的;
- 留给子类protected的接口以访问父类中private的数据;
以上三点在《继承(Inheritance)》一章会有代码演示
5.1、class vs struct:
- 在C++它们都是用来表达类的;
- 区别在于如果在你的类里面存在没有声明访问属性的地方:class缺省为private;struct缺省为public。这是C++中class和struct唯一区别,我们一般首选class。
6、对象组合(Object composition):
OOP三大特性即:封装、继承、多态性。但是从另外一个角度来说“继承是OOP对软件重用的回答”或者说“继承是OOP实现软件重用的一种方式”。这一章要讲的就是“重用的实现”(Reusing the implementation),但这里先不讲继承。在C++里面,我们还可以以另外一种方式实现软件重用,即“组合”(composition):用现有的对象构造新对象。
- 组合的关系是一种“has a”的关系;
比如说谈到一辆车,我们会说这辆车 has a 引擎,has a 方向盘,has a 空调,has a 轮胎......如果我们已经有了引擎、方向盘、空调、轮胎......的对象,我们把它们放在一起,再加一些其他的细节,以这种方式来实现软件的重用,于是我们组合出了一辆车对象。
- “组合”其实在谈OOP的五条原则五条原则时提到过,即“对象里面还是对象”;
- 反映到C++的代码上即我们在设计一个类的时候它的成员变量可以是另外一个类的对象。在实际设计中,C++提供了两种不同的内存模型:fully & by reference;
- fully表示“那个别的类的对象就是我这个类里的一部分(成员变量是对象本身)” ;
- by reference表示“那个别的类的对象我知道在哪里,我可以访问到它(成员变量是指针),我可以调用它的方法,但它并不是我这个类里的一部分” ;
class A{ private: ... public: ... }; class B{ private: A a; //fully A* aa; //by reference B* bb; /*by reference允许成员变量的类型是其本身,而fully无法做到这一点(会陷入无限循环) 因为指针对编译器来说就仅仅是一个指针而已,无论指针所指的类型是什么, 它永远都只是那4个字节(32位系统),编译器不需要知道指针的细节, 指针的细节只有到用的时候才需要,所以不会陷入死循环。 在C语言中的“链表”就是这么干的*/ public: ... }; /*实际设计中,采用fully还是by reference是根据语义来的, 你认为合适把那个对象直接放在你的类里面,你就用fully,不合适就用by reference 比如你设计一个“同学”对象:他的“大脑”对象显然应该放在“身体”(类)里面(fully), 而他的“书包”对象就不太适合放在“身体”里面(by reference)。 或者以设计一个“收音机”对象为例,作为收音机你肯定是可以听电台嘛,但是在设计“收音机”类 的时候你不应该直接把一个“电台”对象(包括什么录音室、主持人、热线电话、接线员等等等等) 直接放(fully)进你的类里面吧,往往都是通过固定频段(比如FM101.7)去访问电台对象吧, 这个固定频段就可以理解为指针嘛(by reference)*/
- 在组合中我们并不希望破坏对象的边界,于是更好的做法便是各个对象的初始化由初始化列表调用各自的构造函数完成,初始化列表也就是用来干这个的。以下面这段代码为例:
/*
*银行储蓄账户对象实例,类中包含人和货币对象(演示代码不能运行)
*/
class Person { ... };
class Currency { ... };
class SavingAccount {
public:
SavingAccount( const char* name, const char* address,
int cents ) : m_saver(name, address), m_balance(0, cents) { ... } //构造函数
//我们在初始化列表中调用了m_saver和m_balance的构造函数,然后把相应的参数传给他们
~SavingAccount() { ... } //析构函数
void prints(){
m_saver.print();
m_balance.print();
}
private:
Person m_saver; //fully
Currency m_balance; //fully
};
/*
*附加思考题:
*这里组合进来的两个对象m_saver和m_balance的属性是private
*假如我们把他俩放到public里面去会怎样呢?
*那我们就有可能做这样的事情:
* m_account.m_saver.set_name("Fred");(假设Person类有set_name())
*虽然m_saver和m_balance是对象,但是他俩也是SavingAccount类的成员变量;
*在OOP理论阶段学习过成员变量作为数据应该是包裹起来不被外界直接访问的。
*所以这显然不是OOP喜欢的,因为他突破了边界,外界可以直接访问里面的数据了
*/
再举一个可运行的简单代码例子:
#include <iostream>
using namespace std;
class A {
private:
int i;
public:
A(int i) { this->i = i; cout << "A::A(),A::i=" << this->i << endl; }
};
class B {
private:
A a;
int j;
public:
B(int j) : a(j+1) { this->j = j; cout << "B::B(),B::j=" << this->j << endl; }
//在B的初始化列表中调用了a的构造函数,并将j+1作为参数传给了他
};
int main() {
B b(10);
return 0;
}
运行结果:
7、继承(Inheritance):
不同于组合,继承是拿已有的类克隆一份,然后对复制品在已有的基础上增添一些细节或者做一些改造,得到一个新的类。 在《This关键字的出现》那我们提到过“类是虚的,对象才是实的”,所以我们可以理解为C++里继承是玩虚的,组合是玩实的。
- 继承是C++语言一门重要的技术,也是面向对象设计方法的重要组成部分;
- 继承使得我们可以共享设计中的:成员数据、成员函数、接口(一个类中对外公开的部分称之为“接口”);
- 继承是将一个类的行为或实现定义为另一个类的超集(superset)的能力;
- 对于继承来说,类之间的关系是一种“is a”的关系;
以上图为例,我们可以说:A student is a person.
- 从关系上说,我们引入一些新的名词用于描述继承者(Student)和被继承者(Person)
- C++继承语法为:类名后面冒号public另外一个类,于是它就是另外一个类的子类了,如下代码所示:
#include <iostream>
using namespace std;
class A {
private:
int i;
public:
A():i(0) { cout << "A::A()" << endl; } //构造函数
~A() { cout << "A::~A()" << endl; } //析构函数
void print() {cout << "A::f(),i=" << i << endl; }
protected: //protected访问属性详情参考《访问限制(Setting limits)》一章
void set(int ii) { i = ii; }
};
class B : public A{ //C++继承语法,表示B类是A类的子类
public:
void f() {
set(20); print(); //子类中新增的函数可以调用父类中public的函数
//i = 30; //子类不能直接访问父类中private的成员变量
}
//需要注意的是,子类虽然拥有父类private的东西,但是不能直接访问他们
};
int main() {
B b; //子类拥有父类的包括public和private的所有东西
//b.set(10); //protected访问限制main里面(外界)不能调用set函数
b.print();
b.f(); //子类还可以在父类基础上拓展新东西
return 0;
}
运行结果:
- 父类、子类、用户类(外界)三者的关系可由下图表示:
7.1、父类子类的关系:
在《对象组合(Object composition)》一章中我们提到过:各个对象的初始化由初始化列表调用各自的构造函数完成。当时给出的理由是为了避免破坏对象的边界,但是却没有尝试如果不这样做会怎么样。
由《继承(Inheritance)》一章中我们得知子类拥有父类的所有东西,这其实可以看作是父类整个“fully”进子类了。所以,在创建子类的对象时,父类的构造函数会自动被调用。《继承(Inheritance)》一章中的演示代码可以看到类A有一个default constructor,为了演示初始化列表的重要性,这里我们对演示代码做一些小改动,如下所示:
#include <iostream>
using namespace std;
class A {
private:
int i;
public:
A(int ii):i(ii) { cout << "A::A()" << endl; } //A的构造函数改为带参构造函数
~A() { cout << "A::~A()" << endl; }
void print() {cout << "A::f(),i=" << i << endl; }
void set(int ii) { i = ii; }
};
class B : public A{ //C++继承语法,表示B类是A类的子类
public:
void f() { set(20); print(); }
};
//B类没有自己的构造函数
int main() {
B b; //生成子类对象
return 0;
}
产生了如下错误:
同样B类中都没有构造函数,为什么仅仅把A类的构造函数改成带参构造函数就无法运行呢?而且这个错误信息看起来毫无头绪。
这就要提到C++里继承的本质了。我们在创建B的对象时,首先会分配一块空间给B,随后进行初始化。而B的对象里面有A的所有东西,所以要初始化B的对象,那么B里面的A类的对象的那部分也要被初始化。
而显然,A的带参构造函数没有被正确调用,那么编译器会去寻找默认构造函数去调用。这一点在《构造和析构(Constructor & Destructor)》一章中提到过。
所以,无论是组合还是继承这一点都是一样的:当你的身体里有其他类的对象的时候,你不懂怎么去初始化他,必须把初始化的工作交给他们自己去做。这样做对象的边界仍然是清晰的,也可以避免一些莫名其妙的错误。
所以,上面的代码你必须得想办法去调用A的构造函数传参给它,而我们知道构造函数都是创建对象时自动调用的,而我们没法主动调用它。怎么做呢?答案就是:初始化列表。所以,我们只需要给B加个构造函数,比如下面这样:
class B : public A{
public:
B () : A (15) {}
void f() { set(20); print(); }
};
最后提一下:当父类和子类都有自己的构造函数和析构函数时,创建子类对象会先构造父类,再构造子类;退出时先析构子类,再析构父类(先进后出) 。
7.1.1、名字隐藏(Name hiding) :
在C++中有一个仅此一家的机制:名字隐藏。以下方代码为例:
#include <iostream>
using namespace std;
class A {
private:
int i;
public:
A(int ii) :i(ii) { cout << "A::A()" << endl; }
~A() { cout << "A::~A()" << endl; }
void print() { cout << "A::print()" << endl; }
void print(int i) { cout << i; print(); } //父类中有函数重载
void set(int ii) { i = ii; }
};
class B : public A {
public:
B() : A(15) { cout << "B::B()" << endl; }
~B() { cout << "B::~B()" << endl; }
void print() { cout << "B::print()" << endl; }
//同时子类中出现了与父类重复的函数(函数名相同,参数表相同)
void f() { set(20); print(); }
};
int main() {
B b;
b.set(10);
b.print();
b.f();
b.print(200); //ERROR
//子类拥有父类的所有东西,所以按理说B的对象b应该是有父类中print(int i)函数的
return 0;
}
给出了如下错误信息:
这件事就称之为“名字隐藏” ,简单来说:假设父类中有一组重载函数,子类在继承父类时如果"覆盖"了这组重载函数中的任意一个,则其余没有被"覆盖"的同名函数在子类中是不可见的。只有C++是这么干的,其他OOP语言都不会出现这种情况。那么,C++为什么会这么干呢?
这其实还和另外一件事情只有C++这么干的有关系:以上面代码为例,对C++来说,子类中的print()函数跟父类中的print()函数是其实是没有关系的;其他OOP语言在同样的情况下两个print()函数会构成一种关系:override(覆盖)。而C++认为他俩没关系的,只是碰巧重名了;而同时正因为他俩没关系,所以父类中的所有重载函数也必须得和子类没关系才行,要不然就乱套了。
如果你还是想要调用那个print(int i),那句错误代码就要改成下面这样:
b.A::print(200);
改正后运行结果如下:
8、函数重载和缺省参数(Function overloading & Default arguments):
所谓函数重载是指一些函数可以具有相同的函数名,却拥有不同的参数表,他们之间便构成了重载的关系。请注意返回类型不是构成重载的条件(如果两个函数拥有相同的名称和参数列表,但是返回类型不同,是不构成重载关系的)
- 对于存在重载的函数,如果你的变量需要做投射时,编译器就会试图去寻找参数列表完全匹配的函数;如果没有找到匹配的,编译器则会认为对重载函数的调用是不明确的(ambiguous);
#include <iostream>
void f(short i) {
std::cout << "short value = " << i << std::endl;
}
void f(double i) {
std::cout << "double value = " << i << std::endl;
}
int main() {
short i = 6;
double j = 6.66;
char a = 'a';
f(i);
f(j);
f(a); //ambiguous
return 0;
}
错误信息:
- 所谓“缺省参数”,你可以在函数声明中预先给函数的参数表里部分或全部参数一个值,如果没有在函数调用中提供值,编译器将自动插入预先给的值;
- 声明一个有参数列表的函数时,缺省参数必须从右到左添加;
- 缺省参数是写在头文件(.h)里的,并且不能在.cpp里面重复一遍;
a.h
void f(int m, int n, short i = 6, double j = 1.23);
//void f(int m, int n, short i = 6, double j) {} //ERROR:默认实参不在形参列表的结尾
a.cpp
#include <iostream>
#include "a.h"
using namespace std;
void f(int m, int n, short i, double j) { cout << "j=" << j << endl; }
//void f(int m, int n, short i = 6, double j = 1.23) { cout << "j=" << j << endl; }
//ERROR:“f” : 重定义默认参数: 参数 1, 参数 2
main.cpp
#include "a.h"
int main() {
f(1,2,3,4.44); //m = 1; n = 2; i = 3; j = 4.44
f(5,6,7); //m = 5; n = 6; i = 7; j = default
return 0;
}
运行结果:
有一点不要忘了:C/C++中"#include"的本质。编译器在编译之前有一个“预处理”过程,在预处理过程中,.h的内容会被展开到.cpp文件里面去。所以当你没有#include a.h的时候直接在main.cpp文件里把a.h的内容复制进来同样可以运行。看起来你好像把缺省参数写在.cpp文件里了,实际上你只是帮助编译器完成了“预处理”这一步的工作。就像下面这样:
a.cpp
#include <iostream>
using namespace std;
void f(int m, int n, short i, double j) { cout << "j=" << j << endl; }
main.cpp
void f(int m, int n, short i = 6, double j = 8.88);
int main() {
f(1, 2, 3, 4.44); //m = 1; n = 2; i = 3; j = 4.44
f(5, 6, 7); //m = 5; n = 6; i = 7; j = default
return 0;
}
运行结果:
- 最后,缺省参数在实际工程中并不建议使用,因为容易造成阅读代码上的困难。还是以上面代码为例:当你看到我在main.cpp中调用了f(5, 6, 7); 的时候,你可能会以为这是一个只有三个参数的f函数;另外一方面是缺省参数很不安全,因为缺省参数是可以改动的,可能会偏离设计者的初衷;
9、内联函数(Inline functions):
函数调用的额外开销:在执行命令之前,设备所需的处理时间
- push参数进栈
- push返回地址
- 准备返回值(x86汇编一般会用AX(accumulator)累加寄存器存放返回值)
- pop all pushed
C++提供了一个手段以避免上面这些额外开销:内联函数。如果这个函数是内联的,当我们去调用该函数时,C++不会真的去调用函数,去做那些“Push、Prepare、Call、Pop、Return”等等动作;而是把那个函数的代码嵌入到调用它的地方去,并且同时还会保持函数的独立性(函数有自己的空间:比如函数有自己的本地变量,进去的时候存在,出来就不存在了;或者调用函数时需要对参数进行检查等这些事情都还是保留的)。
内联前:
int f(int i) {
return i * 2;
}
int main() {
int a = 4;
int b = f(a);
return 0;
}
/*对应汇编代码:
* _f_int:
* add ax,@sp[-8],@sp[-8]
* ret
* _main:
* add sp,#8
* mov ax,#4
* mov @sp[-8],ax
* mov ax,@sp[-8]
* push ax
* call _f_int
* mov @sp[-4],ax
* pop ax
*/
内联后:
inline int f(int i) { //加上inline关键字
return i * 2;
}
int main() {
int a = 4;
int b = f(a); //实际生成的代码:int b = a+a;
return 0;
}
/*对应汇编代码:
* _main:
* add sp,#8
* mov ax,#4
* mov @sp[-8],ax
* add ax,@sp[-8],@sp[-8]
* mov @sp[-4],ax
*/
//可以看到最终生成的可执行代码里面是没有那个内联函数的,省去了很多工作
- 一个内联函数在.obj文件(Linux平台下为.o文件)里可能不会生成任何代码;
- 与缺省参数不同的是,内联函数要求在声明和定义的时候都需要重复“inline”关键字,我们简化一下缺省参数里面那个代码例子;
a.cpp
#include <iostream>
#include "a.h"
using namespace std;
inline void f(int m, int n) {
cout << "m=" << m << ", n=" << n << endl;
}
a.h
inline void f(int m, int n);
main.cpp
#include "a.h"
int main() {
f(10, 10);
return 0;
}
上面这段代码我们确实已经在声明和定义的时候都重复“inline”关键字了,但是运行还是会报错:
由错误信息可以发现,这个错误并不是编译器给出的,而是链接器(Linker)给出的。这意味着编译那关是过了的,到链接时才发现main函数里面要用的f(int,int)不存在。这很奇怪啊:我明明说了函数是inline的啊,调用的时候应该不需要去找啊,直接把那个函数的代码嵌入到调用它的地方去就可以了啊?
我们重新审视下main.cpp,我们先右击项目名称,打开“属性”
找到“预处理器”,将“预处理到文件”改为“是(/P)”
回到解决方案资源管理器,右击main.cpp,选择“编译”
输出栏显示生成成功后,我们打开项目所在文件夹,在“Debug”文件下找到“main.i”并打开
打开后如下图:
由于编译器同一时间只能看得到1个文件,所以在编译main.cpp时,这就是编译器能看到的所有内容。在这里面编译器知道了f是个inline的函数,但是并没有看到f函数长什么样子的,显然我们没有把f函数的“body”放在这里。所以编译器只能放弃把f函数做内联,在这里产生一个对f的调用。所以这个f函数的inline在main.cpp里面就变成没有意义的了。
但是在编译a.cpp的时候就不一样了,编译器明确看到在函数声明和定义时都重复了“inline”,所以编译器会认为“OK,我不需要去产生任何代码” 。所以a.obj里面是没有任何f函数的痕迹存在的。
当Link的时候,问题就出现了:main.cpp里说“我要个f函数”,而a.cpp里面又没有f。于是,我们就会看到最开始给出的那条错误信息。
解决方案:我们需要把f函数的“body”放到a.h里头去,移除a.cpp文件。如下代码所示;
a.h
#include <iostream>
using namespace std;
//inline void f(int m, int n); 定义都有了,声明也就无关紧要了
inline void f(int m, int n) {
cout << "m=" << m << ", n=" << n << endl;
}
main.cpp
#include "a.h"
int main() {
f(10, 10);
return 0;
}
运行结果:
综上所述:
- 对于内联函数来说,.cpp文件是完全不需要的,在.h里面把所有内联函数的“body”放进去就可以了;
- 完全不需要担心内联函数的重复定义问题,因为对内联函数来说,它的“定义”(definition)实际上就只是“声明”(declaration);
- 是否使用内联函数取决于你自己权衡:
- 内联函数的“body”会被插入到每一处调用它的地方,如果调用比较多,程序就会比较“臃肿”了;
- 虽然牺牲了代码空间,但是有效降低了调用函数的额外开销;
- 所以这是一种典型的“空间换时间”的策略;
- 在大多数情况下,这都是值得的(现在计算机内存那么大,随便造;但是工程中对时间的要求可能比较严格);
- 内联函数这种方式要比C语言的“宏”要好得多(C的宏也可以做类似的事情,但是宏是不能做类型检查的);而inline作为函数是可以由编译器做类型检查的;
//C语言宏定义
#define f(a) (a)+(a)
main() {
double a=4; //宏不进行类型检查,所以a可以是double
printf("%d",f(a)); //显然,C语言中double类型不能用%d输出
}
//C++内联函数
inline int f(int i){
return i*2;
}
main() {
double a=4;
printf("%d",f(a)); //编译器会进行类型检查然后发现问题,如下图:
}
另外还有一些内联函数的tips:
- 当编译器发现你的“inline”函数过于庞大或者有递归(call itself)存在,编译器会拒绝其成为内联函数;
- 你在类的声明(declaration)中定义(define)的任何函数都默认为内联函数;
- 这里说的"类的声明"不是指仅仅有个 class 类名、而没有类体的类的前向声明啊,而是指我们在《C/C++中.h与.cpp文件》小节中说的"放在头文件中的类定义体是在做声明"
- 第二点还有一种做法是在类的声明中只放函数声明,然后把这些函数的“body”放在class的后面并为每一个函数加上“inline”关键字,如下图所示。这样做的好处是保持了class比较干净,有哪些东西可以一目了然。
- 综合以上所有:
- 以下几种情况建议内联:
- 只有两到三行的小函数;
- 频繁调用的函数(比如函数调用处在循环里,就会被频繁调用);
- 以下几种情况不建议内联:
- 非常大的函数(比如超过20行的函数);
- 递归函数;
10、Const:
在C语言中,我们已经学习过一次const了,意思是“const的变量被初始化之后不能被赋值”,不过对于C++来说,const的变量仍然是变量,而不是常数,这是不一样的。因为对编译器来说,变量意味着它真的要在内存里面给你分配地址的,而常数意味着这只是编译器在编译过程中记在自己内存表里的一个实体。
而且,const的变量仍然遵循范围规则(scope rule),如果是本地变量,即便const了,还是进函数才有,出函数就没了。
- const int bufsize = 1024;
- 这是一个编译时刻知道值的const;
- 它的值必须初始化;
- 它可以用来定义数组长度(int buf[bufsize];)
- 除非你添加“extern”显式声明;
- int x; cin >> x; const int bufsize = x;
- 这是一个编译时刻不知道值的const;
- 它不可以用来定义数组长度
- extern const int bufsize;
- 编译器不会允许你修改其值;
- 这句代码意思是“bufsize是定义在某处的全局变量,同时这个全局变量是const”。对编译器来说:你说它是const,那么我就要求这个bufsize你可以用,但是你不能修改 。这和bufsize本身是不是真的是const没有关系;
- 它同样不可以用来定义数组长度;
- 此外还有用const修饰指针:详情请移步至:浅谈const int *,int const *与int *const
-
void f(const int* x) /*表示可以给这个函数任何int变量(无论是不是const), 对调用f函数的人来说,这表示你传给f函数的虽然是指针, 但f函数保证不会对你的东西做任何修改*/
-
- const可以修饰成员函数,表示该函数不会改变类的成员变量,也不会在该函数中调用类中其他非const的成员函数;
- 在声明和定义的时候都要重复 const 关键字;
- 实质上是表明 this 是const(这一点会在下一章详细讲);
- const可以修饰函数返回值,不过我们知道像返回int这种基本类型数据实际上是在返回一个值,返回值的函数不能作左值;
除非你函数返回一个指针,但如果返回的指针是const那么它带星号 *(f()) 也不能作左值了。
对一个函数传进传出整个对象时可能会造成很大的开销(传参需要在堆栈里分配空间,意味着在堆栈里要花很多时间空间做拷贝工作),往往更好的办法是传一个地址。但是传地址我们又会很不放心别人会不会通过指针修改我们的原始数据。
这个时候,const修饰指针的作用就来了。我们在前面加上 const 表明我们以一个const的方式传一个对象进去,这样就可以保证我们的数据是安全的。
#include <iostream> using namespace std; class A { private: int i; public: void set_i(int ii) { i = ii; get_i(); cout << "i = " << i << endl; } int get_i() const { return i; } A() :i(0) { cout << "Now in A::A(),i=" << i << endl; } ~A() {i = 20; cout << "Now in A::~A(), i=" << i << endl; } }; void set_i(const A* a) { int test = 9; a->get_i(); //get_i()是const修饰的成员函数,表示不会动成员变量,所以可以调用 //a->set_i(test); //C2662 “void A::set_i(int)”: 不能将“this”指针从“const A”转换为“A& ” } int main() { A* a = new A; set_i(a); delete a; return 0; }
运行结果:
- const可以修饰整个对象,表明对象里的值是不能被修改的(常量对象),这其实就是和const int、const char等是一回事(别忘了面向对象的5条原则之“万事万物皆是对象”,一个int、一个char都是对象)。一旦将对象定义为const之后,该对象的任何非 const 成员函数都不能被调用,因为任何非 const 成员函数可能会修改对象的数据(编译器也会这样假设),C++禁止这样做。
由上面这段话不难看出,C++中 const 一个对象看起来和const int、const char等是一回事,实际上却跟const int* p;更像:编译器会禁止你尝试(通过调用对象的非const成员函数)修改数据,但这不代表数据(成员变量)真的是const的(除非你成员变量也加了const修饰)。你可以在自动调用的构造和析构函数中随意修改成员变量的值,以如下代码为例:
#include <iostream>
using namespace std;
class A {
private:
int i; //私有的非const成员变量i
public:
int j; //公开的非const成员变量j
const int k = 6; //公开的const成员变量k
void set_i(int ii) { i = ii; cout << "i=" << i << endl; }
int get_j() const { return j; }
A() { i = 0; j = 1; cout << "Now in A::A(),i=" << i << endl; }
~A() { i = 20; j = 21; cout << "Now in A::~A(), i=" << i << endl; }
};
int main() {
const A* a = new A;
//const修饰A类的对象指针a,当然可以直接const对象“const A a;”(类似于const int a;)
//a->set_i(10); //无法调用非const成员函数
cout << "j=" << a->get_j() << endl; //可以调用const的成员函数
cout << "j=" << a->j << endl;
cout << "k=" << a->k << endl; //可以访问const or 非const的公开的成员变量
delete a; //new了记得delete
return 0;
}
运行结果:
可以看到如果对象里面的函数要修改对象里的值的话,调用那种函数就会出问题。为了避免这种问题,你可以在声明和定义函数时在函数名和函数体之间加上 const 修饰,表明函数不会去修改任何成员变量;而且vs比较聪明,在发现对象是 const 之后会自动屏蔽所有的非const成员函数,也会在你尝试调用非const函数时报错(即便你那个非const成员函数并未修改任何成员变量)。如下图:
10.1、字符串字面值(String literals):
#include <iostream>
using namespace std;
int main() {
char *s = "Hello world";
cout << s << endl;
s[0] = 'B';
cout << s << endl;
return 0;
}
上面的代码在Visual Studio里无法运行,给出了如下错误:
而我们修改下代码:
#include <iostream>
using namespace std;
int main() {
char s[] = "Hello world";
cout << s << endl;
s[0] = 'B';
cout << s << endl;
return 0;
}
就可以成功运行了:
这是怎么一回事呢?
问题出在s是本地变量,存放在堆栈里面。
当s作为指针,指向了一块内存,这块内存放了个字符串;而"Hello world"是个常量且编译器会认为这是个const的东西。这其实是一个“ const char* s ”不过是编译器接受不加“ const ”的写法,所以它是放在“代码段”里面的。这个时候s只是存放了"Hello world"所在的代码段的地址,而代码段是不可写的。
而当s作为一个数组,整个数组都存放在堆栈里面。这个时候代码会变成它要对"Hello world"整个做一个拷贝,它会把代码段里的"Hello world"拷贝到堆栈里面来。后续的修改也是对拷贝过来的副本做的修改。
实际上我们可以证明这件事的:分别打印s1、s2、main函数的地址
#include <stdio.h>
int main() {
const char* s1 = "Hello world";
char s2[] = "Hello world";
printf("s1 = %p\n", s1);
printf("s2 = %p\n", s2);
printf("main = %p\n", main);
return 0;
}
运行结果:
显然,s1和main函数处在一个段(代码段);s2处在堆栈段。
11、不可修改的对象(Constant object):
在上一章我们提到过用const修饰成员函数时,这实质上是表示 this 是const的,但并未展开讲。这一章我们就来详细证明一下。看下面这段代码:
#include <iostream>
using namespace std;
class A{
private:
int i;
public:
A() : i(0) {}
void f() { cout << "A::f()" << endl; }
void f() const { cout << "A::f() const" << endl; }
};
int main(){
A a;
a.f();
const A aa;
aa.f();
return 0;
}
看起来这段代码编译会报错,因为显然 f() 函数重复定义了(看起来函数名相同,参数列表相同,不构成重载关系)。但实际上呢?我们运行一下:
实际上可以运行,问题的关键就在:用 const 修饰成员函数实质上是表明 this 是const;
两个 f() 函数的参数列表实际上是不同的:
void f(A* this) { cout << "A::f()" << endl; } void f(const A* this) { cout << "A::f() const" << endl; }
这两个函数实际上构成了重载关系的,在调用时会根据对象是否是const而选择不同的 f() 函数;
11.1、Constant in class:
- 成员变量是const:
- 必须在初始化列表里初始化;
- 不能用来定义数组长度(除非在前面再加个static修饰,这一点等到讲static的时候再讲);
#include <iostream> using namespace std; class A { private: const int i; int array[i]; public: A() : i(10) {} }; int main() { return 0; }
- 还有一个办法是用枚举:
#include <iostream> using namespace std; class A { private: enum { i = 20 }; int array[i]; //it's OK! public: A() {} }; int main() { return 0; }
上半部分学习笔记完结,点击前往:下半部分学习笔记https://blog.csdn.net/YMGogre/article/details/126952858