一、面向对象的思想
面向对象是一种以你办事我放心为理想构造出来的东西。这也是一个很好的鉴别一个面向对象的设计是否正确的方法。一个好的面向对象设计,会让你让他办事的时候,你不得不放心(也就是说,你不放心也没用,反正你什么都不知道)。
(1)面向对象程序设计
概念:(Object Oriented Programming,缩写:OOP)是一种程序设计范型,同时也是一种程序开发的方法。 对象指的是类的实例,将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性。
(2)C++和面向对象
在面向对象的世界里,用类一个个的构造出对象来,在主程序里调用的是一个个对象的行为。
在C++程序里,数据和对数据的处理都被封装在了一个对象里。我们对对象进行修改,使用对象的数据。
二、C++的特性
1.封装:
将数据和对该数据进行合法操作的函数封装在一起作为一个类的定义,用类定义的变量去对类进行操作。
封装可以通过 访问限定符 对外提供访问方式。
(1)访问限定符:
public(公有),protected(私有),private(私有)。
- public成员可从类外部直接访问,private/protected成员不能从类外部直接访问。使用了private,成员只能在自己的类使用,而使用protected,出了自己的类可以使用,继承的类也可以使用。
2.每个限定符在类体中可使用多次,它的作用域是从该限定符出现开始到下一个限定符之前或类体结束前。
3. 类体中如果没有定义限定符,则默认为私有的。
4. 类的访问限定符体现了面向对象的封装性。
(2)封装好处:
将变化隔离。
便于使用。
提高重用性。
提高安全性。
2.继承:
继承是面向对象复用的重要手段。通过继承定义一个类,继承是类型之间的关系建模,共享公有的东西,实现各自本质不同的东西。
(1)基类成员在派生类的访问关系
基于public继承,赋值兼容规则
子类对象可以赋值给父类对象(切割/切片)
父类对象不能赋值给子类对象
父类的指针/引用可以指向子类对象
子类的指针/引用不能指向父类对象(可以通过强制类型转换完成)
(2)派生的默认成员函数
在继承关系里面,在派生类中如果没有显示定义这六个成员函数,编译系统则会默认合成这六个默认的成员函数:构造函数、拷贝构造函数、析构函数、赋值操作符重载、取地址操作符重载、const修饰的取地址符重载
(3)继承体系中的作用域
在继承体系中基类和派生类都有独立的作用域。
子类和父类中有同名成员,子类成员将屏蔽父类对成员的直接访问。(在子类成员函数中,可以使用基类::基类成员 访问)
注意在实际中在继承体系里面最好不要定义同名的成员。
(4)单继承和多继承
单继承: 一个子类只有一个直接父类时称这个继承关系为单继承
多继承: 一个子类有两个或以上直接父类时称这个继承关系为多继承
(5)多继承中的菱形继承
菱形继承造成Assistant类中有两份来自Person类的成员,这样造成了数据冗余(rǒng yú)和二义性。为此,我们引入虚继承。
(6)虚继承
在继承定义中包含了virtual关键字的继承关系
虚继承解决了在菱形继承体系里面子类对象包含多份父类对象的数据冗余&浪费空间的问题。
虚继承体系看起来复杂,在实际应用我们通常不会定义如此复杂的继承体系。一般不到万不得已都不要定义菱形结构的虚继承体系结构,因为使用虚继承解决数据冗余问题也带来了性能上的损耗。
(7)虚函数和多态:
①虚函数
虚函数必须是基类的非静态成员函数,其访问权限可以是protected或public
在基类的类定义中定义虚函数的一般形式:virtual 函数返回值类型 虚函数名(形参表) { 函数体 }
②虚函数用法注意:
只有类的成员函数才能说明为虚函数;
静态成员函数不能是虚函数;
内联函数不能为虚函数;
构造函数不能是虚函数;
析构函数可以是虚函数,而且通常声明为虚函数。
③为什么内联函数不能声明为虚函数:?
首先,内联函数是静态行为,而虚函数是动态行为,他们之间是矛盾的。
其次,内联函数是在编译期就将代码展开并替换;虚函数则是到了运行时才识别调用。
④类内的成员函数定义的都是默认的内联函数和内联函数不能成为虚函数之间是否矛盾?
类内的成员函数虽说声明为内联函数,但该函数是否是内联函数还要看编译器决定。声明为内联只是给编译器提供一个建议,但具体实行还是要看编译器决定。
⑤纯虚函数:
是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”。
格式:class <类名>
{
virtual <类型><函数名>(<参数表>)=0;
…
};
⑥为什么引入纯虚函数?
经常会用到多态,此时基类就要定义虚函数。而在有些情况下父类生成对象是不合理的或是不必要的,就要定义为纯虚函数。比如,动物可以派生出老虎,狮子等,但如果让动物生成对象就不合理了。
(7)总结:
①基类的私有成员在派生类中是不能被访问的,如果一些基类成员不想被基类对象直接访问,但需要在派生类中能访问,就定义为保护成员。可以看出保护成员限定符是因继承才出现的。
②public继承是一个接口继承,保持is-a原则,每个父类可用的成员对子类也可用,因为每个子类对象也都是一个父类对象。
③protetced/private继承是一个实现继承,基类的部分成员并未完全成为子类接口的一部分,是 has-a 的关系原则,所以非特殊情况下不会使用这两种继承关系,在绝大多数的场景下使用的都是公有继承。
④不管是哪种继承方式,在派生类内部都可以访问基类的公有成员和保护成员,但是基类的私有成员存在但是在子类中不可见(不能访问)。
⑤使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
⑥在实际运用中一般使用都是public继承,极少场景下才会使用protetced/private继承.
3.多态
按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态。简单的说,就是允许将子类类型的指针赋值给父类类型的指针。多态性在Object Pascal和C++中都是通过虚函数实现的。
class Person
{
public:
virtual void Behavior()
{
cout << "work." << endl;
}
};
class Student :public Person
{
public:
virtual void Behavior()
{
cout << "study." << endl;
}
};
void Fun(Person& p)
{
//当使用基类的指针或引用,调用方式:
//1.指向父类的调用就是父类的虚函数
//2.指向子类的调用就是子类的虚函数
p.Behavior();
}
int main()
{
Person p;
Student s;
Fun(p);//父类对象,调用父类虚函数
Fun(s);//子类对象,调用子类虚函数
}
Student继承父类Person。在Fun函数内虽然形参是Person类对象p的引用,但是传入对象不同,行为也不同,这样就构成了多态。Student的Behavior对父类的Behavior构成了重写(覆盖)。此时调用函数根据传入对象来调用该对象的函数。
(1)重写:
当在子类的定义了一个与父类完全相同的虚函数时,则称子类的这个函数重写(也称覆盖)了父类的这个虚函数。
重载:在同一作用域下,函数名相同,参数列表不同。
重定义(隐藏):在基类和派生类中,函数名相同,但没有构成重写。
(2)总结
- 派生类重写基类的虚函数实现多态,要求函数名、参数列表、返回值完全相同。(协变除外)
- 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性。(即只要基类定义虚函数,派生类覆盖该函数不写virtual,系统也会默认该函数为虚函数)
- 只有类的成员函数才能定义为虚函数。 如果在类外定义虚函数,只能在声明函数时加virtual,类外定义函数时不能加virtual。
- 静态成员函数不能定义为虚函数。
- 构造函数不能为虚函数,虽然可以将operator=定义为虚函数,但是最好不要将operator=定义为虚函数,因为容易使用时容易引起混淆。
- 不要在构造函数和析构函数里面调用虚函数,在构造函数和析构函数中,对象是不完整的,可能会发生未定义的行为。
- 最好把基类的析构函数声明为虚函数。(另外析构函数比较特殊,因为派生类的析构函数跟基类的析构函数名称不一样,但是构成覆盖,这里是因为编译器做了特殊处理)
4.三大特性总结
封装可以隐藏实现细节,使得代码模块化,继承可以扩展已存在的模块,它们目的都是为了:代码重用。而多态是为了实现另一个目的:接口重用。
三、类的6个默认成员函数
在说成员函数之前提一点,每个成员函数(除了构造函数)形参内都有一个this指针,指向当前对象,由编译器处理。this指针是隐式的,不可显示的在形参中定义和传对象地址给this指针。
1.构造函数
(1)函数名和类名相同,用来初始化类成员,生成对象时会自动调用相应的构造函数。
(2)可以在类内定义,也可在类外定义。如果类内没有构造函数,系统会自动生成一个缺省的构造函数,否则不会生成。
(3)无参和全缺省的构造函数都是缺省构造函数,且类内只能有一个缺省的构造函数。
(4)构造函数支持函数重载,但没有返回值。
class Person
{
public:
//和类名相同,是构造函数,无参构造函数
Person()
{
}
//带参构造函数
Person(char* _name , int _age ):name(_name),age(_age)
{
}
char* name;
int age;
};
int main()
{
Person p1;
Person P2("LiMing",20);
cout << P2.name << " " << P2.age << endl;
}
初始化列表
以一个冒号开始,接着一个逗号分隔数据列表,每个数据成员都放在一个括号中进行初始化。
(1)为什么初始化列表比较快?
从概念上来讲,构造函数的执行可以分成两个阶段,初始化阶段和计算阶段,初始化阶段先于计算阶段。
在代码上讲,主要是性能问题,对于内置类型,如int, float等,使用初始化类表和在构造函数体内初始化差别不是很大,但是且如果不写初始化列表,在构造函数系统也会默认初始化,而函数体内又写一遍造成冗余,且对于类类型来说使用初始化列表少了一次调用默认构造函数的过程。
(2)哪些成员变量必须放在初始化列表里面?
常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里
没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。
2.拷贝构造函数
(1)又称复制构造函数,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。
(2)其唯一的形参必须是引用(避免死循环,实参传递给形参,形参拷贝构造实参,拷贝构造又去调拷贝构造),一般会加上const限制。
(3)如果没有定义系统会默认缺省的拷贝构造函数,依次对成员进行初始化。
(4)拷贝构造就是构造的重载。
class Person
{
public:
Person(){}
Person(const Person& p)
{
name = p.name;
age = p.age;
}
char* name;
int age;
};
int main()
{
Person p1;
Person p2(p1);//拷贝构造,将p1拷贝给p2
Person p3=p1;//同样是拷贝构造
}
3.析构函数
(1)函数名和类名相同,前面加上~符号
(2)析构函数没有参数和返回值
(3)一个类只有一个构造函数,没有定义的话,系统会自动生成。
(4)对象的生命该周期结束时,系统会自动调用析构函数。且析构函数体并不是删除对象,而是清理工作。
class Person
{
public:
Person()
{
name = (char*)malloc(4);
int age = 10;
}
~Person()//析构函数
{
free(name);
name = NULL;
}
char* name;
int age;
};
int main()
{
Person p1;
}
4.赋值操作符重载函数
运算符重载特征
①operator+合法运算符,构成函数名(.* / : / sizeof / ?: / .)用 / 隔开
②重载运算符后,不能改变运算符的优先级/结合性/操作个数
③拷贝构造是创建对象,使用一个已有的对象来初始化这个准备创建的对象。
④赋值运算符,是把一个对象传递给另一个已经存在的对象。
class Person
{
public:
Person() {}
Person(char* _name, int _age)
{
name = _name;
age = _age;
}
Person(const Person& p)
{
name = p.name;
age = p.age;
}
Person& operator=(Person& p)
{
if (this != &p)
{
delete[] name;
name = new char[sizeof(p.name)];
strcpy(name, p.name);
this->age = p.age;
}
return *this;
}
char* name;
int age;
};
int main()
{
Person p1("liming", 20);
Person p2;
p2 = p1;//简单的赋值运算符
cout << p2.name << " " << p2.age << endl;
}
5.取地址符操作符重载函数
Person * operator&()
{
return this;
}
6.const修饰的取地址操作符重载函数
const Person * operator&()
{
return this;
}
四、创建对象对构造函数、拷贝构造函数、赋值运算符的调用
题目:
class Person{};
Person f(Person a)
{
return a;
}
void Test1()
{
Person p1;
p1 = f(p1);
}
void Test2()
{
Person p1;
Person p2 = f(p1);
}
void Test3()
{
Person p1;
Person p2 = f(f(p1));
}
Test1中调用了 1 次Person的构造函数,调用了 2 次Person的拷贝构造函数, 1 次Person的赋值运算符函数的重载。
Test2中调用了 1 次Person的构造函数,调用了 2 次Person的拷贝构造函数, 0 次Person的赋值运算符函数的重载。
Test3中调用了 1 次Person的构造函数,调用了 3 次Person的拷贝构造函数, 0 次Person的赋值运算符函数的重载。
在类Person内打印出具体函数调用。
class Person
{
public:
Person()
{
cout<<"构造函数"<<endl;
}
Person(Person&a)
{
cout << "拷贝构造" << endl;
}
Person& operator = (Person&a)
{
cout << "赋值运算符重载" << endl;
return *this;
}
};
五、类的大小?为什么要内存对齐?内存对齐的计算?空类的计算
1.类的简单定义:
(1)通过类名定义对象来访问类成员和成员函数。因为,类成员是私有,而成员函数是公有,因此外部对象只能访问public成员。但是在类内,私有成员可以被类内成员函数访问。对象可以通过这种方式对私有成员进行访问。
(2)关于类的大小,首先要说的是类本身并没有大小可言。通常所说类的大小是指类实例化出对象的大小。就像设计房子一样,类只是一个设计图,画着房子的框架,有着家具的布局,但是本身没有大小,只有实例化盖好的房子才有大小可言。通常用sizeof算类的值,得到的是用该类实例化出对象的大小。
2.关于类/对象大小的计算
(1)类大小的计算遵循结构体的对齐原则,可以参考博客https://mp.csdn.net/mdeditor/78733006
(2)类的大小与普通数据成员有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响
(3)虚函数对类的大小有影响,是因为虚函数表指针带来的影响
(4)虚继承对类的大小有影响,是因为虚基表指针带来的影响
(5)空类的大小是一个特殊情况,空类的大小为1。(空类大小设计成1是为了保证类实例化出的对象存在,编译器会隐含加一个字节)
class Person {
public:
char name;
char sex;
int age;
static long long num;
};
class Student
{
public:
int snonum;
Person p;
};
int main()
{
printf("a = %d\n", (int)sizeof(Person));//8
printf("b = %d\n", (int)sizeof(Student));//12
return 0;
}
知识拓展
六、创建对象时加括号与不加括号有什么区别
1.普通创建对象
加括号,声明了一个是类名返回值类型的函数
不加括号,自动调用构造函数创建对象。
2.new关键字创建对象时
对于内置类型:加括号会初始化为0,不加括号不会初始化,只是开辟对应类型大小的空间。
对于自定义类型,都会调用默认构造函数,加不加括号没区别。
class Person
{
public:
Person(char* _name = "LiMing" , int _age = 20 )//带参构造函数
{
name = _name;
age = _age;
}
char* name;
int age;
};
Person p1()
{
Person _p;
return _p;
}
int main()
{
Person();//匿名对象
Person p;//在栈上创建对象
Person p1();//声明,返回值类型为Person的函数
Person *p2 = new Person();//在堆上创建一个--调用全缺省构造函数的--匿名对象,并由p2指向它
Person *p3 = new Person;//在堆上创建一个--调用默认构造函数的--匿名对象,并由p2指向它
char *p4 = new char('a');//在堆上开辟一个char类型的空间,并初始化为字符a,由指针p4指向
char *p5 = new char();//在堆上开辟一个char类型的空间,初始化为0,由指针p5指向
char *p6 = new char;//在堆上开辟一个char类型的空间,不初始化,由指针p6指向
}