什么是面向对象?
面对这个疑问,相信很多写了多年代码的朋友们一时也不知道怎么回答。引用一位大神的话说:OOP编程是利用“类”和“对象”来创建各种模型来实现对真实世界的描述。
什么是“对象”?对象,是不以人的意志为转移而又与自我的存在通过感性确定性进行关联的 客体事物,是简单的、直接性的存在、本质性的 现实。在哲学上,对象是简单、直接的存在本质,具有现实的本质性,是知识的生成者,没有对象就没有知识。是感性确定性中必要的两个条件之一,另一条件则是自我,缺一不可。对象本身是存在的,它的存在不依附任何关系。在计算机中,数据封装形成的实体就是对象。对象是类的实例化。一些对象是活的,一些对象不是。在现实生活中一个实体就是一个对象,如一个人、一个气球、一台计算机等都是对象。比如这辆汽车、这个人、这间房子、这张桌子、这株植物、这张支票、这件雨衣。 概括来说就是:万物皆对象。在面向对象的程序设计中,对象是系统中的基本运行实体,是代码和数据的集合。 在应用领域中有意义的、与所要解决的问题有关系的任何事物都可以作为对象,它既可以是具体的物理实体的抽象,也可以是人为的概念,或者是人和有明确边界和意义的东西。一个对象 即是 一个类 的 实例化后实例,一个类必须经过实例化后方可在程序中调用,一个类可以实例化多个对象,每个对象亦可以有不同的属性。
什么是类?一个类即是对一类拥有相同属性的对象的抽象、蓝图、原型。在类中定义了这些对象的都具备的属性(variables(data))、共同的方法。
面向对象编程三大特性
封装,继承,多态。
初识:
封装:类定义将其说明(用户可见的外部接口)与实现(用户不可见的内部实现)显式地分开,其内部实现按其具体定义的作用域提供保护。对象是封装的最基本单位。封装防止了程序相互依赖性而带来的变动影响。面向对象的封装比传统语言的封装更为清晰、更为有力。
继承:一个类可以派生出子类,在这个父类里定义的属性、方法自动被子类继承。
多态:一个接口,多种实现。一个基类中派生出了不同的子类,且每个子类在继承了同样的方法名的同时又对基类的方法做了不同的实现,这就是同一种事物表现出的多种形态。
深入:
继承:
首先区分:“是”关系和“有”关系。
“是”关系表示继承。
“有”关系表示合成。
派生类的成员函数 不能直接 访问基类的私有成员。如果派生类能访问基类的私有成员,则从这个派生类继承的类也同样能访问这些数据。
派生类成员函数可以引用基类的 公有成员 和 保护成员,只需使用成员名即可。当派生类的成员函数重新定义基类的成员函数时,为了从派生类中访问基类的成员,可以在基类成员名前面加上基类名和二元作用域分解操作符 (::)
派生类 构造函数 调用 基类构造函数 时,如果 实参个数 和 类型 与 基类构造函数定义中指定的 参数个数 和 类型 不一致,就会发生编译错误!
在派生类构造函数中,用成员初始化器列表初始化成员对象并显式地调用基类的构造函数(当基类中不包含可以被隐式调用的默认构造参数),可以防止重复初始化。当调用默认构造函数时,如果数据成员在派生类的构造函数中被再次修改,就会发生重复初始化。
在派生类头文件中用#include包含基类的头文件。
这样做的原因:
1、为了让派生类能在自己的代码区域中,使用基类的名称,必须告知编译器这个基类是存在的。
2、告知编译器能够为这个对象保留适当数量的内存。
3、使得编译器能够判断派生类是否正确地使用了从基类继承的成员函数。
基类的保护成员,可以由基类的成员和友元访问,也可以由派生类的成员和友元访问。
在现实开发中:最好用私有数据成员促进良好的软件工程,让编译器解决代码优化问题。
使用保护数据成员(protected)可能会导致两个严重的问题:
1、派生类对象可以不使用成员函数而直接设置基类的保护数据成员的值,可能导致对象不一致状态。
2、派生类成员函数的代码编写很可能依赖于基类的实现。增加了耦合性。
当基类只应该向它的派生类(以及友元)而不是其它客户提供服务(即成员函数)时,最好使用protected访问限定符。
将基类的数据成员声明为 私有(private)的,就使得程序员能在改变基类的实现时,不必修改派生类的实现(尽量使用这种实践)。
虽然成员函数访问数据成员时,比直接访问数据稍慢一些,但是,当今编译器会优化,可以隐式的执行许多优化操作。
在 派生类中重新定义基类的构造函数时,派生类版本通常会调用基类版本,完成部分工作,在引用基类的成员函数时,如果忘记了在函数名前加上 基类名和 ::操作符,则会导致无限递归。
派生类中的构造函数和析构函数
当程序创建派生类对象时,它的构造函数会立即调用基类的构造函数。首先执行的是基类的构造函数体,然后才是派生类的成员初始化器,最后执行的是派生类的构造函数体。对于包含两级以上的类层次,这一过程就是这样逐级进行的。
析构顺序:按照构造过程 相反 的顺序依次被调用。
虚析构的意思:如果只调用了基类的析构函数 ,能够使编译器帮忙关联调用相关派生类的析构函数。保证程序的正确析构。
基类的构造函数、析构函数和重载的赋值运算符并不会被派生类继承。但是派生类的构造函数、析构函数和重载的赋值运算符,可以调用基类的构造函数、析构函数和重载的赋值运算符。
析构类似 栈 操作。
多态:
多态:需要使用基类指针句柄和基类引用句柄,而不能使用名称句柄。
多态的关键:每个对象知道如何响应相同的函数调用,做正确的事情。同一消息发送到不同对象时得到的结果具有”许多形式“,因此称为”多态。
利用多态,可以设计和实现易于扩展的系统。多态的两项底层技术:虚函数 和 动态绑定。
何时发生多态:当通过 基类指针 或 引用调用 虚函数时,就会发生多态----C++会动态地(即在执行时)选择实例化对象的那个类的正确函数。
1、派生类对象的地址 赋予 基类指针,然后通过这个 基类指针调用函数,最终调用的是 基类的功能,即句柄的类型决定了会调用哪个函数。
2、不能将基类对象的地址赋予派生类指针,会编译错误!!
3、通过将 基类函数 声明为 virtual ,引入虚函数和多态。然后,将派生类对象的地址赋予基类指针,并使用这个指针调用派生类的功能,得到的功能正是所期望获得的多态行为。
4、如果显式地将 基类指针 强制转换为 派生类指针,则编译器就 允许 通过这个指针访问 只在 派生类中存在的 成员。这种技术被称为向下强制转换。(必须这么做)
虚函数:
利用虚函数,指针所指向的对象类型(不是句柄类型)决定了调用的是虚函数的哪个版本。
动态绑定:
在执行时选择合适的函数调用,这个过程。
只能通过指针或引用才能发生动态绑定。
将基类指针指向派生类对象时,试图用这个基类指针引用派生类才有的成员,是错误的。
抽象类:
不应该实例化任何对象的类。
存在的目的:提供适当的基类,让其它类继承。通过将类的一个或多个虚函数声明为”纯的“,可以使这个类成为抽象类。
纯虚函数:
纯虚函数没有提供实现,它要求派生类必须重写这个函数才能成为具体类。
虚函数提供了实现,允许派生类选择是否重写这个函数。
如果在基类中实现函数没有什么意义,而是希望让它的所有派生类实现这个函数,则应该将这个函数声明为 纯虚函数。
抽象类:
抽象类定义了类层次中的各种类的共同公共接口。
抽象类不能被实例化。
抽象类中至少需要一个纯虚函数。
虚函数表:
C++编译包含一个或多个虚函数的类时,会为这个类建立一个vtable(虚函数表)。每当调用类的虚函数时,正在执行的程序都会利用这个vtable选择适当的函数实现。利用多态以及使用向下强制转换、dynamic_cast、typeid和type_info运行时类型信息RTTI 和 动态强制转换功能。
dynamic_cast<目标类型>(原类型);
#include <typeinfo>
typeid();
虚析构函数:
如果指向派生类对象的 基类指针 使用delete运算符,显式地销毁这个具有非虚析构函数的派生类对象,则C++标准指定这种行为是未定义的。解决办法:用关键字 virtual 声明基类中的析构函数。
当销毁派生类对象时,这个派生类中的基类部分也会被销毁,因此派生类和基类的析构函数都应该执行。基类的析构函数,会在派生类的析构函数执行之后自动执行。
可保证:当派生类对象通过基类指针删除时,将会自动调用自定义的派生类析构函数(如果存在);
类深入研究:
先看常对象和常量成员:常对象与常量成员函数:
1、将需要修改类的数据成员的成员函数定义成const。错误!
2、如果将成员函数定义为const,但它在同一个实例上调用了类的一个非常量成员函数。错误!
3、在常量对象上调用非常量成员函数。错误!。
4、常量成员函数可以被重载为非常量的版本
5、 一旦将对象定义为 常对象 之后,就 只能 调用类的 const 成员(包括 const 成员变量和 const 成员函数)
小插曲零散的总结:
构造函数和析构函数,不允许被声明为const。但可以用于初始化常量对象。
在构造函数中调用非常量成员函数,作为常对象初始化的一部分。
常对象的”常量性‘是在 构造函数 完成了对象的初始化之后 生效的,直到 调用了对象的析构函数。
成员初始化器列表 是在执行构造函数体 之前 执行的。
常对象不能通过赋值修改,因此必须初始化它。当 类的数据成员 用const声明时,必须使用成员初始化器,向构造函数提供类对象的数据成员的初始值。对 引用 而言,也应该这么做。
常量数据成员(常量对象和常量变量)以及被声明为引用的数据成员,都必须用成员初始化器语法初始化,在构造函数体中对这些类型的数据赋值是不允许的。
友元:
类友元函数是在类的作用域外定义的,但它仍有具有访问这个类的非公有成员的权限。独立的函数或整个类都可以被声明成另一个类的友元。
应将所有的友元关系声明放在类定义体的开始部分,并且在其前面不应带有任何访问权限限定符。
友元关系是授予的,而不是获得的。比如为了使 类B 成为 类A 的友元,类A必须显式地将类B声明为它的友元。
友元关系不是对称的、也不是传递的。比如类A是类B的友元,而类B是类C的友元,那么不能说明类A与类C,类B是类A的友元。
将重载函数指定为类的友元也是可行的,想成为友元的每个重载函数,都必须在类定义中显式地声明成这个类的友元。
this指针:
成员函数 如何知道 要操作哪个对象的数据成员呢?
每个对象都可以通过一个称为this(C++关键字)的指针访问自己的地址。
对象的this指针,并不是对象本身的一部分。所以不要用sizeof对其求值。
this指针是作为一个隐式的实参(由编译器)传递给对象的每个非静态成员函数的。
一个有趣的应用场景:
防止对象被赋值为自身。(当对象包含指向动态分配的内存的指针时,自身赋值会引起错误)。使用this指针实现串联式函数调用。(return *this)点运算符的结合性是从左向右的。
用new 和 delete操作符实现动态内存管理
什么是动态内存管理?
C++允许程序员在程序中控制它们的内存的分配和释放。
堆:是为每个程序所分配的内存区域,用于存储动态分配的对象。
new操作符 可以用来动态分配任何 基础类型 或 类类型 的对象。如果new 无法在内存中找到足够空间,将会抛出异常提示出错。
什么是内存泄漏?
当不再需要动态分配的内存时,如果不释放它,则可能使系统耗尽内存。
静态类成员:
当类的所有对象只使用数据的一个副本就足够了时,应该将这个数据声明为静态数据成员,以节省空间。类的静态成员,只具有类作用域。当不存在类的对象时,为了访问这个类的公有静态成员,只需简单地在数据成员的前面放上类名和二元作用域分解操作符(::)即可。静态成员函数是类所提供的服务,而不是类的某个特定对象所提供的服务。不能在文件作用域的静态数据成员的定义中包含关键字 static。
不能将静态成员函数声明为 const,因为const限定符表明函数不能修改它所操作的 对象 的内容,但静态成员函数的存在和操作是独立于类的任何对象。
如果成员函数 不需要 访问非静态数据成员或非静态成员函数,则应该将他们声明为静态的。
什么是数据抽象?
客户只关心有什么功能,不关心这些功能怎么实现的。
代理类:
良好的软件工程的两个基本原则:将接口与实现分离以及隐藏实现细节。
作用:对类的客户隐藏类的私有数据。向类的客户提供一个只知道类的接口的代理类,从而使得使用类的服务的客户无法知道类的实现细节
预处理器包装器:
#ifndef __XXX_XXX_H__
#define __XXX_XXX_H__
......
#endif
防止头文件中的代码被多次包含到同一个源代码文件中
小插曲零散的总结:
三种句柄访问类的公有成员:
1、对象名称,2、对象引用,3、指向对象的指针。. 号表示:点成员运算符。
类的非静态数据成员不能在声明的时候初始化。
如果成员函数在类定义体中定义的,则编译器会试图通过内联方式调用它。在类定义的外部定义的成员函数,可以使用inline关键字显示的进行内联调用,编译器保留是否内联的权利。
只有最简单、最稳定的成员函数(即它的实现基本不会改变)才应该在类的头文件中定义
//举例子:
//一旦定义了Time类之后,它可以用作对象、数组、指针和引用声明的一种类型:
class Time
{
};
Time sunset; //object of type Time;
Time arrayOfTimes[ 5 ]; //array of 5 Time objects
Time &dinnerTime = sunset; //reference to a Time object
Time *timePtr = &dinnerTime; //pointer to a Time object
合成和继承的概念:
通常,类并不是从零开始,而是可以包含其它类的对象作为自己的成员,或者从其它类派生,继承新类能够使用的属性和行为。合成:将类的对象作为其它类的成员,称为合成(聚合)。继承:从现有的类,派生新类的方法
对象的大小?
sizeof运算符,只会得到类的数据成员的 大小。因为:编译器只会创建成员函数的一个副本,它独立于类的所有对象!类的所有对象共用这个副本!当然每个对象都需要拥有类数据的一个副本,所以这些数据会因为对象而不同。
分离接口与实现:
无法简单的通过将成员函数的声明放在头文件,定义放在源代码文件来安全分离,因为:内联成员函数需要放在头文件中,以便编译器编译客户代码时,客户可看到内联函数的定义。类的私有成员是在头文件的类定义中列出的,以便这些成员对客户可见,虽然客户可能无法访问这些私有成员。具体解决办法:使用 “代理类” ,在类的客户面前隐藏私有数据。
设计一个代理类:
#include <iostream>
using namespace std;
class Implementation{
public:
Implementation(int v): value(v){}
void setValue(int v){
value = v;
}
int getValue() const{
return value;
}
private:
int value;
};
class Delegate{
public:
Delegate(int v): pi(new Implementation(v)){}
void setValue(int v){
pi->setValue(v);
}
int getValue() const{
return pi->getValue();
}
private:
Implementation *pi;
};
int main()
{
Delegate d(5);
d.setValue(100);
cout<<d.getValue()<<endl;
return 0;
}
默认构造函数:
每个类最多只能有一个默认构造函数。
构造函数可以调用类的其它成员函数,但由于构造函数负责对象的初始化,因此数据成员此时可能还未处于一致状态,在数据成员未被适当地初始化之前就使用她们,可能导致逻辑错误
析构函数:
当销毁类的对象时,就会隐式地调用这个类的析构函数。析构函数本身不释放对象的内存,它在对象的内存回收之前执行终止性的清理工作,使这些内存可以被复用,以便保存新的对象。析构函数不接收任何实参,没有返回值。一个类只能有一个析构函数,不能重载析构。析构函数必须声明为public
何时调用构造函数和析构函数?
构造函数和析构函数是由编译器 隐式 调用的。
abort函数与exit函数会迫使程序立即终止,而不允许调用任何对象的析构函数。
微妙的陷阱:
返回私有数据成员的引用。。(应该避免使用)
让类的公有成员函数返回这个类的私有数据成员的引用。
注意:如果函数返回一个常量引用,则这个引用不能用作可修改的左值。
返回私有成员数据的引用或指针,会破坏类的封装,并使客户代码依赖于类的数据的表示形式。应该避免
对象可以作为函数的实参,也可以从函数返回。这种传递和返回是通过 按值 传递执行的,传递或返回的是 对象的副本。在这种情况下,C++会创建一个新对象,并使用拷贝构造器将原始对象的值复制到新对象中。对于每个类,编译器都提供一个默认的拷贝构造器,它会将原始对象的每个成员复制到新对象的对应成员中。和逐成员赋值一样,如果类的数据成员包含指向动态分配内存的指针,则拷贝构造函数就可能产生严重错误。
按常量引用传递是一种安全的、执行起来不错的方案