QT(简单了解C++)

本文为QT学习前置的C++基础教程,聚焦面向对象编程核心特性:类与对象、构造/析构函数、this指针、继承(公有/保护/私有)、函数与运算符重载、多态(虚函数与动态绑定)、数据封装(public/private/protected)及抽象类(纯虚函数)。内容强调概念理解与Qt应用关联,涵盖命名空间、new/delete内存管理机制,并对比C与C++在IO、类型定义等方面的差异。

前言:本章将简单学习C++,为后续的QT学习打下基础

在学习C++之前,我们先下载好QT,版本是5.12.9,下载好之后

(1)下载qt并创建一个工程

我们开始创建第一个项目流程如下:

点开文件、点击new project(新建项目),然后看到如下画面
在这里插入图片描述

选择Non-Qt Project、Plain、 C++ Application(普通 C++ 应用),然后点击选择,接着就会看到下面的画面

在这里插入图片描述

工程名称、文件保存按自己需求来,配置好后点击下一步

在这里插入图片描述

选择qmake,继续点击下一步

在这里插入图片描述

选择 Desktop Qt 5.12.9 MinGW 64-bit,然后下一步,就创建好了,如下:

在这里插入图片描述
(1)C++ 语言定义了一些头文件,这些头文件包含了程序中必需的或有用的信息。上面这段程序中,包含了头文件 。

(2)using namespace std:告诉编译器使用 std 命名空间。命名空间是 C++ 中一个相对新的概念。其中 std 就是 C++里的标准命名空间,也就是标准库里写好的了,我们可以直接调用。

(3)int main() 是主函数,程序从这里开始执行

(4)cout << “Hello World”<<endl; 会在屏幕上显示消息 "Hello World"并换行。“<<”是运算符,endl 是换行语句

然后可以点击右下角的绿色三角形运行,也可以Crtl + R运行

一切没问题后,我们开始学习C++

(2)C++简介

C++ (c plus plus)是一种静态类型的、编译式的、通用的、大小写敏感的、不规则的编程语言,支持过程化编程、面向对象编程和泛型编程。C++ 被认为是一种中级语言,它综合了高级语言和低级语言的特点。C++ 是由 Bjarne Stroustrup 于 1979 年在新泽西州美利山贝尔实验室开始设计开发的。C++ 进一步扩充和完善了 C 语言,最初命名为带类的 C,后来在 1983 年更名为 C++。C++ 是 C 的一个超集,事实上,任何合法的 C 程序都是合法的 C++ 程序。截止 2020 年,在 2017 年发布 C++17,已经是第五个 C++标准了。我们也见过或者听过 C++98,这样的 C++标准,也就是 1998 年发布的 C++,所以叫 C++98,是 C++的第一个标准。

学习 C++我们要理解概念,而不是深究语言技术细节。我们只要学习C++基础概念就可以了,这对于后续学习 Qt 或者写 C++会有一定的帮助。

(3)C++基础

这里主要介绍概念为主,主要介绍 C++与 C 语言中常用的不同点,和一些新的变化。其中不会去说指针、数据类型、变量类型、判断和循环等这些知识,这些和C 语言基本是一样使用的。我们主要学习 C++的面向对象编程,对学习 Qt 有很大的帮助。Qt 里就能体现到 C++编程带来的优势和便处。就算没学过 C++,学习 Qt 也不会很难。写 C++基础这章,笔者已经把重要的概念写出来,但是实际上 C++的内容不止这么多本章是快餐式 C++入门,主要是为了更好的理解 Qt 中的 C++语法,学习 Qt时也方便理解其中的内容。

1.C++语言新特性

C++比 C 语言新增的数据类型是布尔类型(bool)。但是在新的 C 语言标准里已经有布尔类型了,但是在旧的 C 语言标准里是没有布尔类型的,编译器也无法解释布尔类型。

在传统的 C 语言里,变量初始化时必须在程序的前面定义在前面,而 C++则是可以随用随定义。C++也可以直接初始化,比如 int x(100);这样就直接赋值 x=100,这些都是 C++特性的好处。这里只说这些常用的新特性,其他特性不做描述或者解释了。

2.C++的输入输出方式

在 C 语言里,我们是这样输入或者输出的。

在这里插入图片描述

在 C++里,我们使用以 cin 和 cout 代替了 scanf 和 printf。在输入和输出的流程上是不变的,只是关键字变了,用法也变了。

在这里插入图片描述

要说效率上,肯定是 C 语言的 scanf 和 printf 的效率高,但是没有 C++中的 cin 和 cout 使用方便。

C++的 I/O 语法方式如下:

cout<<x<<endl;

x 可以是任意数据类型,甚至可以写成一个表达式,这比 C 语言需要指定数据类型方便多了,endl 指的是换行符,与 C 语言的“\n”效果一样。这比我们的printf()方便多了

错误示例:

cout<<x,y<<endl; // 在变量间不能用“,”。

正确写法:

cout<<x<<y; // endl 可流省略,只是一个换行的效果。 

cin 语法形式:

cin>>x; 	//x 可以是任意数据类型。 

拓展,如何输入两个不同的变量。

cin>>x>>y; 

3.C++之命名空间 namespace

前面我们已经见过命名空间了,如下代码第 2 行。using namespace std;同时我们要注意第 1 行,不能写成 iostream.h,有.h 的是非标准的输入输出流,c 的标准库。无.h 的是标准输入输出流就要用命名空间。

#include <iostream> 
using namespace std; 
int main() 
{ 
	cout << "Hello, World!" << endl; 
	return 0; 
} 

using 它是 C++ 关键字,声明当前命名空间的关键词。可以从字面上理解它的意思,using 翻译成使用。这样可以理解成使用命名空间 std。因为 cin 和 cout 都是属于 std 命名空间下的东西,所以使用时必须加上 using namespace std;这句话。cin 和 cout 可以写 std::cin 和 std::cout,“::”表示作用域,cin 和 cout 是属于 std 命名空间下的东西,这里可以理解成 std 的 cin 和 std 的 cout。 为什么要使用命名空间?

有些名字容易冲突,所以会使用命名空间的方式进行区分,具体来说就是加个前缀。比如 C++ 标准库里面定义了 vector 容器,你自己也写了个 vector 类,这样名字就冲突了。于是标准库里的名字都加上 std:: 的前缀,你必须用 std::vector 来引用。同理,你自己的类也可以加个自定义的前缀。但是经常写全名会很繁琐,所以在没有冲突的情况下您可以偷懒,写一句 using namespace std;,接下去的代码就可以不用写前缀直接写 vector 了。

从命名空间开始我们就隐隐约约可以看到 C++面向对象的影子了。命名空间在很多 C++库里使用到。有些公司也会自定义自己的 C++库,里面使用了大量的命名空间。从这里我们也可以看出 C++是非常之有条理的,容易管理的,不含糊,易使用的。

在初学 Qt 时我们是比较少使用命名空间,或者比较少看到命名空间。当然也是可以在 Qt里自定义命名空间,然后与 C++一样正常使用。

下面我们编写自己的命名空间和使用自己的命名空间来加深理解,代码如下:

#include <iostream>

using namespace std;

namespace A
{
    int x=0;
    void fun(void)
    {
        cout<<"my namespace"<<endl;
    }
};

using namespace A;

int main()
{
    cout << "Hello World!" << endl;
    cout << x << endl;
    fun();
    x=3;
    cout<<x << endl;
    return 0;
}

可以看到我们自定义了一个命名空间 A,然后该空间有一个初始值为 0 的 x 变量,还有一个 fun() 函数,该函数会打印 "my namespace"。接着在主函数之前加一句 using namespace A;,后面再调用该空间的成员,就不用加 A:: 的前缀了。可以类比成我们创建一个“姓氏家族”,using namespace 的意思就是声明后面都是遇到该家族的人就不用喊姓氏了,直接叫名字就可以了。

4.C++面向对象

面向对象的三大特征是继承,多态和封装,C++重面向对象重要的就是这些,我们下面通过一些简单的实例加以理解,从这小节开始,我们将开启新的编程旅途。与 C 语言编程的思想完全不同了,这就是 C++!理解概念和掌握这些编程方法对学习 C++有很大的好处。

(1)类和对象

C++ 中的类(class)是一种编程结构,用于创建对象。这些对象可以拥有属性(即数据成员)和行为(即成员函数或方法)。类的概念是面向对象编程的核心之一,其主要目的是将数据和与数据相关的操作封装在一起。例如,如果你有一个“汽车”类,它可能包含颜色、品牌、型号等属性(数据成员),以及启动、停止、加速等行为(成员函数)。每当你基于这个类创建一个对象时,你就有了一个具体的汽车,具有这些属性和行为。

C++ 类的基本结构通常包含:
1. 数据成员(Attributes):定义类的属性。这些是类内部的变量,用于存储对象的状态。
2. 成员函数(Methods):定义类的行为。这些是可以操作对象的数据成员的函数。
3. 构造函数和析构函数:特殊的成员函数。构造函数在创建对象时自动调用,用于初始化对象。析构函数在对象销毁时调用,用于执行清理操作。
4. 访问修饰符:如 public , private , protected ,用于控制对类成员的访问权限。例如,public 成员可以在类的外部访问,而 private 成员只能在类内部访问。
5. 继承:允许一个类继承另一个类的特性。这是代码重用和多态性的关键。
通过这些特性,C++ 类提供了一种强大的方式来组织和处理数据,使得代码更加模块化、易于理解和维护。

从类中实例化对象分两种方法,一种是从栈中实例化对象,一种是从堆中实例化对象。 下面以自定义狗类介绍如何自定义类和如何使用对象。

#include <iostream>

using namespace std;

namespace A
{
    int x=0;
    void fun(void)
    {
        cout<<"my namespace"<<endl;
    }
};

class dog
{
public:
    string name;
    void fun1(void)
    {
        cout<<"dog name:"<<endl;
    }
};

using namespace A;

int main()
{
    /*在栈中实例化对象*/
    dog B;
    B.name="B";
    B.fun1();
    cout<<B.name<<endl;

    /*在堆中实例化对象*/
    dog* C=new dog();
    C->name="C";
    C->fun1();
    cout<<C->name<<endl;
    
    delete C;
    
    C=NULL;
    return 0;
}

在这里插入图片描述

(1):定义一个类,类名是dog,这其实跟我们定义结构体是很相似的

(2)访问限定符: public(公有的),此外还有 private(私有的)和 protected(受保护的)。写这个的目的是为了下面我们要调用这些成员,不写访问限定符默认是 private,即我们不能访问这些成员。简单的来说,访问限定符就是设置一个成员变量和成员函数的访问权限而已,初学者暂时不必要深究什么时候应该用 public 和什么时候应该用 private。

(3)定义一个字符串name,在C++中,不用像C语言一样要先定义一个数组,然后再设置字符串,而是直接一个String就可以了,很方便

(4)在我们C语言中,结构体的函数成员只能是该函数的指针,但是在C++中就能在类里面定义该函数

(5)从栈中实例化一个对象 B。

(6)从堆中实例化一个对象C

那什么是从栈中实例化一个对象和从堆中实例化一个对象呢?如下:

Dog dog1;              // 在栈上创建对象
Dog *dog2 = new Dog;   // 在堆上动态分配对象
对比项Dog B;Dog *C= new Dog;
对象在哪当前函数的栈帧里堆里
B / C 是什么B 就是对象本身C 是栈上的指针,指向堆里的对象
生命周期离开作用域自动结束直到 delete 才结束
析构函数离开作用域自动调用delete 时调用
地址运行时由 SP + 偏移决定运行时由分配器决定
风险栈空间有限;不能返回局部对象地址内存泄漏、悬空指针、内存碎片

一句话总结:栈对象省心,堆对象灵活。日常写小对象优先用栈,需要跨作用域存活、体积大或数量不定时再用堆,并且记得配对 delete(现代 C++ 更推荐用 std::unique_ptr 等智能指针托管堆对象)。

(2)new关键字

在上面学习在堆实例化对象的时候,我们调用了new关键字,那new关键字是什么呢?

new作用:

void *mem = operator new(sizeof(Dog));  // 1. 分配原始内存
Dog *dog2 = new (mem) Dog;             // 2. 在 mem 上构造 Dog(placement new)
  1. 分配 sizeof(Dog) 字节的内存;
  2. 在这块内存上调用 Dog 的构造函数,把对象初始化好;
  3. 返回一个 Dog* 指针。

这与我们C语言的malloc()是不同的,malloc只会分配内存,但不会构造这块内存

对比项new Dogmalloc(sizeof(Dog))
分配内存✅✅
调用构造函数✅❌
返回类型Dog*,类型安全void*,要强转
失败时默认抛 std::bad_alloc返回 NULL
释放delete(析构 + 释放)free(只释放)
适合C++ 对象C 的原始内存

一句话总结:new 是"分配 + 构造"一步到位,malloc 只负责"分配"。所以在 C++ 里创建对象请用 new,并且用 delete 配对释放;malloc / free 留给纯 C 的原始内存场景,两者千万不要混用(new 出来的内存用 free 释放,或者 malloc 出来的内存用 delete 释放,都是未定义行为)。

delete作用:

dog2->~Dog();              // 1. 调用析构函数
operator delete(dog2);     // 2. 释放内存

最后简单来说:malloc 是买一块毛坯地;new 是买地 + 按图纸建好房子 + 通水电(构造函数);delete 是拆房子 + 退地;free 只是退地,不拆房子。

(3)构造函数与析构函数

什么是构造函数?构造函数在对象实例化时被系统自动调用,仅且调用一次。构造函数出现在哪里?前面我们学过类,实际上定义类时,如果没有定义构造函数和析构函数,编译器就会生成一个构造函数和析构函数,只是这个构造和析构函数什么事情也不做,所以我们不会注意到一点。

构造函数的特点如下:

(1) 构造函数必须与类名同名;
(2) 可以重载,(重载?新概念,后面学到什么是重载。);
(3) 没有返回类型,即使是 void 也不行。

什么是析构函数?与构造函数相反,在对象结束其生命周期时系统自动执行析构函数。实际上定义类时,编译器会生成一个析构函数。

析构函数的特点如下:

(1) 析构函数的格式为~类名();
(2) 调用时释放内存(资源);
(3) ~类名()不能加参数;
(4) 没有返回值,即使是 void 也不行。

构造/析构的特点

对比项构造函数析构函数
名字与类同名,如 Dog()~Dog()
返回类型没有返回类型(void 也不行)没有返回类型
参数与重载可以重载:Dog()、Dog(int)不能带参数,不能重载
主要职责初始化成员、分配资源释放对象拥有的资源
调用时机每个对象生命周期开始时调用一次生命周期结束时调用一次
补充说明定义了其他构造函数,默认构造不再生成可以用 virtual ~Dog() 支持多态删除

一句话总结:**构造函数负责"生",析构函数负责"死"。构造函数在对象创建时把成员初始化好、把资源申请好;析构函数在对象销毁时把资源还回去。**两者都不写返回类型,也都不需要手动调用——栈对象由编译器在作用域进出时自动调用,堆对象则由 new 和 delete 分别触发。

下面我们通过实验来观察对象,代码如下:

#include <iostream>

using namespace std;


class Dog
{
public:
    Dog()
    {
        cout<<"creat"<<endl;
    }
    ~Dog()
    {
        cout<<"delete"<<endl;
    }
    string name="dogname";
    int age=3;
};


int main()
{
    /*在栈中实例化对象*/
    Dog dog1;
    cout<<dog1.name<<endl;
    cout<<dog1.age<<endl;

    /*在堆中实例化对象*/
    Dog* dog2=new Dog;
    delete dog2;
    /*将指针初始为NULL,因为指向的堆内存已经被释放了*/
    dog2=NULL;

    return 0;
}

实验现象:
在这里插入图片描述
可以看到,实例化对象时系统会自动执行构造函数,释放对象内存时系统会自动调用析构函数。第一次析构函数调用,是因为我们执行了 delete dog2,dog2 指向的堆对象被销毁了;那后面那次析构函数调用呢?还记得前面说的栈对象和堆对象的区别吗?栈对象 dog1 的生命周期在离开作用域时结束,所以当 main() 函数结束时,dog1 也会被自动销毁。我们可以看看如果不释放 dog2 的内存会发生什么。
在这里插入图片描述
可以看到,如果不释放 dog2 对象的堆内存,它并不会随着离开 main() 函数作用域而结束。而且我们可以注意到,"creat" 先被打印出来,说明实例化对象时会先调用构造函数,再执行后面的代码。

其实我们在学习 new 关键字的时候已经见过这个构造函数了:调用 new 时,它会先给该类的对象分配一块堆内存,然后在这块内存上会调用该类的构造函数,把对象初始化好,最后再返回这个对象的指针。

(4)this 指针

同一个类的所有对象,它们的其实都是共享同一份成员函数代码,既然它们共享同一份函数代码,那该成员函数代码是如何区分传来的参数是哪个对象的参数呢?就靠this指针。

  1. 编译器眼中的成员函数
class Dog {
public:
	   void run() { age++; }
	   int age;
};

Dog dog1, dog2;
dog1.run();
dog2.run();

编译器大致会把 run 看成:

void Dog_run(Dog* const this)
{
   this->age++;
}

调用变成:

Dog_run(&dog1);
Dog_run(&dog2);
  • Dog::run 的代码只有一份,在代码段/Flash 里;
  • dog1、dog2 各自只有数据成员 age;
  • this 是隐藏的第一个参数,告诉函数“这次操作哪个对象”。

所以:函数地址是类的那份代码地址,不是每个对象一份。 非虚函数下,&Dog::run 对所有对象都是同一个函数。

(1) this 只能在成员函数中使用,全局函数、静态函数都不能使用 this。实际上,成员函数默认第一个参数为 T * const this。也就是一个类里面的成员了函数 int func(int p),func 的原型在编译器看来应该是 int func(T * const this,int p)。

(2) this 在成员函数的开始前构造,在成员函数的结束后清除。

(3) this 指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。

#include <iostream>

using namespace std;


class Dog
{
public:
    int age;
    void fun()
    {
        age++;		//this->age
        cout<<"the dog's age is:"<<age<<endl;
        cout<<"fun:"<<this<<endl;
    }
};


int main()
{
    Dog dog1;
    Dog dog2;
    dog1.age=3;
    dog1.fun();
    cout<<"main:"<<&dog1<<endl;
    dog2.age=5;
    dog2.fun();
    return 0;
}

可以看到,Dog 类有两个对象 dog1 和 dog2。前面说过,对象只有数据成员,同一个类的所有对象共享的是同一份成员函数。对于类的成员函数,它的默认第一个参数是 T* const this,这个 this 会指向调用该函数的对象的数据成员。

因此我们可以预测实验结果:第一个 fun: 后面的 this 地址,会和后面 main: 输出的 &dog1 地址一样,因为第一次调用 dog1.fun() 时,this 指向的就是 dog1 的数据成员;第二个 fun: 后面的 this 地址,则是 dog2 的数据成员地址,因为第二次调用 dog2.fun() 时,this 指向的是 dog2。

数据结果如下:
在这里插入图片描述
实验结果很好的印证了这点

(5)继承

继承的基本概念:

面向对象程序设计中最重要的一个概念是继承。**继承允许我们依据另一个类来定义一个类,这使得创建和维护一个应用程序变得更容易。**这样做,也达到了重用代码功能和提高执行效率的效果。

当原来的类不满足你的需求时,想要保留该类原来的成员情况下,重新编写新的数据成员和成员函数,只需指定新建的类继承了一个已有的类的成员即可。这个已有的类称为基类,新建的类称为派生类。在 Qt 里大量的使用了这种特性,当 Qt 里的类不满足自己的要求时,我们可以重写一个类,就是通过继承需要重写的类,来实现自己的类的功能。

简单来说就是:继承就是允许一个类(称为派生类或子类)继承另一个类(称为基类或父类)的属性和方法。继承的主要目的是实现代码重用,以及建立一种类型之间的层次关系。

特点

  1. 代码重用:子类继承了父类的属性和方法,减少了代码的重复编写。
  2. 扩展性:子类可以扩展父类的功能,添加新的属性和方法,或者重写(覆盖)现有的方法。
  3. 多态性:通过继承和虚函数,C++支持多态,允许在运行时决定调用哪个函数。

与类的访问修饰限定符一样继承的方式也有几种,如果未使用访问修饰符,则默认为 private。

下面来捋一捋继承的方式,例子都是以公有成员和公有继承来说明,其他访问修饰符和其他继承方式,大家可以在教程外自己捋一捋。这个公有成员和继承方式也没有什么特别的,无非就是不同的访问权限而已,可以这样简单的理解。

  1. 公有继承(public):当一个派生类继承公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
  2. 保护继承(protected): 当一个派生类继承保护基类时,基类的公有和保护成员将成为派生类的保护成员。
  3. 私有继承(private):当一个派生类继承私有基类时,基类的公有和保护成员将成为派生类的私有成员。

下面我们通过实例来学习一下:

#include <iostream>

using namespace std;

class Animal
{
public:
    string type;
    string color;
    void fun()
    {
        cout<<"the Animal is:"<<type<<endl;
    }
};

class Dog : public Animal
{
public:
    int age;
    void dog_fun()
    {
        cout<<"the Animal'age is:"<<age<<endl;
    }
};


int main()
{
    Dog dog1;
    dog1.type="dog";
    dog1.age=8;
    dog1.fun();
    dog1.dog_fun();
    return 0;
}

代码现象如下:
在这里插入图片描述

可以看到,这里是在原来的 Animal 基类基础上,通过继承创建了一个 Dog 派生类。Dog 类在保留 Animal 原有成员的基础上,又新添加了自己的数据成员 age 和成员函数 dog_fun()。在 main() 中我定义了一个 Dog 类的对象 dog1,这个对象不仅能访问 Dog 类自己的成员,还能访问从 Animal 类继承来的成员,例如 type 和 fun()。

注意:在上面代码中我使用的是公有继承,也就是Public,那我们试试其它继承呢,会发生什么呢?如下:
在这里插入图片描述
可以看到,程序在这里直接报错了。这是因为我们把继承方式改成了保护继承,也就是 protected。在保护继承下,从基类继承过来的 public 成员和 protected 成员,都会变成派生类的 protected 成员。因此,在 main() 这样的外部函数里通过派生类对象,就不能直接访问这些从基类继承来的成员了;不过派生类对象仍然可以访问派生类自己新增的 public 成员。而在派生类的内部,则还可以继续访问从基类继承来的成员,如下:
在这里插入图片描述
(补充:需要注意,在上图派生定义的type不是使用基类的name成员,而是重新在派生类定义一个同名的public成员这样做会把基类的同名成员藏起来,如果要使用基类的成员,应该是usying Animal::)

如果我们保护继承呢?会出现什么情况?如下:

在这里插入图片描述
可以看到,在保护继承下,派生类可以访问基类的protected成员,但是不能访问基类的private成员,不然就会报错,对于私有继承也是一样的,如下:
在这里插入图片描述

上面的例子有点乱,从下面这个例子来看看三种继承下,基类、派生类、派生类对象,什么可以访问,什么不可能访问,代码如下:

class Animal
{
public:    int pub;
protected: int pro;
private:   int pri;
};

class Dog : public Animal      // 换成 protected / private 再对照
{
public:
    void test()
    {
        pub = 1;   //using Animal::pub ?
        pro = 2;   //using Animal::pub ?
        pri = 3;   //using Animal::pub ?
    }
};

int main()
{
    Dog d;
    d.pub = 1;   // using Animal::pub?
    d.pro = 2;   // using Animal::pub?
    d.pri = 3;   // using Animal::pub ?
}

总表:基类成员在三种继承下的可见性

继承方式基类成员基类内部派生类内部派生类对象(外部)再派生一层(孙类内部)
publicpub✅✅✅✅
publicpro✅✅❌✅
publicpri✅❌❌❌
protectedpub✅✅❌✅
protectedpro✅✅❌✅
protectedpri✅❌❌❌
privatepub✅✅❌❌
privatepro✅✅❌❌
privatepri✅❌❌❌

派生类自己的成员

继承方式不影响 Dog 自己声明的成员:

Dog 自己的成员Dog 内部外部 d.xxx
public✅✅
protected✅❌
private✅❌

总结:

1.继承方式只影响"从基类继承来的成员",不影响派生类自己声明的成员——Dog 自己写的 public 成员,类内部和外部对象都能访问;自己写的 protected 成员,类内部能访问、外部对象不能访问;自己写的 private 成员,只有类内部(以及友元)能访问,外部对象一律不行。

2.继承方式决定的是"基类成员在派生类里被降级成什么权限"——public 继承保持原权限,protected 继承把 public 降为 protected,private 继承把 public 和 protected 都降为 private;而 private 成员无论哪种继承方式,派生类内部都碰不到,只能通过基类提供的 public / protected 接口间接访问。

对照上面的代码,Dog : public Animal 时,d.pub 可以访问,d.pro、d.pri 都不行;test() 里 pub、pro 可以赋值,pri 会报错。把继承方式换成 protected 或 private 后,d.pub 也会跟着报错,因为它在派生类里已经被降级了。

(6)重载

C++ 允许在同一作用域中的某个函数和运算符指定多个定义,分别称为函数重载和运算符重载

重载声明是指一个与之前已经在该作用域内声明过的函数或方法具有相同名称的声明,但是它们的参数列表和定义(实现)不相同。

当你调用一个重载函数或重载运算符时,编译器通过把你所使用的参数类型与定义中的参数类型进行比较,决定选用最合适的定义。选择最合适的重载函数或重载运算符的过程,称为重载决策。

1.函数重载:

在同一个作用域内,可以声明几个功能类似的同名函数,**但是这些同名函数的形式参数(指参数的个数、类型或者顺序)必须不同。我们不能仅通过返回类型的不同来重载函数。**在 Qt源码里,运用了大量的函数重载,所以我们是有必要学习一下什么是函数重载。不仅在 C++,
在其他语言的里,都能看见函数重载。因为需要不同,所以有重载各种各样的函数。

下面我们通过例子来理解:

#include <iostream>

using namespace std;

class Animal
{
public:
    string type;
    string color;
    void fun()
    {
        cout<<"the Animal is:"<<type<<endl;
    }

private:
    string size;
};

class Dog : private Animal
{
public:
    string name;
    int weight;
    void dog_about(string name)
    {
        cout<<"the dog's about:"<<this->name<<endl;
        cout<<"the dog's about:"<<name<<endl;
    }
    void dog_about(int weight)
    {
        cout<<"the dog's about:"<<weight<<endl;
    }
};


int main()
{
    Dog dog1;
    dog1.name="lala";
    dog1.weight=9;
    dog1.dog_about(dog1.name);
    dog1.dog_about(dog1.weight);
}

实验结果如下:
在这里插入图片描述
通过上面的例子我们可以知道重载函数的使用方法:就是同一个函数名,可以定义不同的参数,可以是类型、数量或者顺序,这里要注意的是不能只是函数返回类型不同,必须通过参数的不同来重载。

上面这个例子还顺带复习了一下 this 关键字:Dog 类定义的对象在调用成员函数 dog_about() 时,确实会把自身对象的地址传给隐藏的 this 指针,所以函数里 this->name 打印的是对象自己的 name,而形参 name 打印的是传进来的实参。也就是说,this 是成员函数里才有的东西,它属于类的那份函数代码,而不是属于对象;对象本身只负责存放数据成员、在构造时被初始化,调用成员函数时再把自己的地址交给 this。

如果用户传入的参数类型不在我们写的重载函数里,那么编译器就会匹配不到相应的重载函数,编译时就会报错。

2.运算符重载

(用的不多)在C++中,运算符重载是一个允许程序员自定义各种运算符,如(+,-,==,!=等)在自定义类型(类或结构体)上的行为的特性。这意味着你可以定义类似于内置类型的运算符行为,使你的自定义类型更加直观和易于使用。

基本原则
① 不可以创建新的运算符:只能重载已经存在的运算符。
② 至少有一个操作数是用户定义的类型:不能重载两个基本类型的运算符。
③ 不能更改运算符的优先级:重载的运算符保持其原有的优先级和结合性

下面是可重载的运算符列表:
在这里插入图片描述
在这里插入图片描述
下面是不可重载的运算符列表:
在这里插入图片描述

上面的描述很抽象,我们通过例子就很容易理解了,如下:

#include <iostream>

using namespace std;

class Animal
{
public:
    string type;
    string color;
    void fun()
    {
        cout<<"the Animal is:"<<type<<endl;
    }

private:
    string size;
};

class Dog : private Animal
{
public:
    int weight;
    /* 重载运算符为 + */
    int operator+(const Dog d)
    {
        Dog dog;
        dog.weight=this->weight+d.weight;
        return dog.weight;
    }
};


int main()
{
    Dog dog1;
    Dog dog2;
    Dog dog3;
    dog1.weight=10;
    dog2.weight=20;
    dog3.weight=dog1.weight+dog2.weight;
    cout<<"the dog's weight:"<<dog3.weight<<endl;
}

可以看到我们定义了一个int类型的**operator+**函数,operator右边的+符合就是重载符合,对于重载符合:this永远会指向重载符合的左边对象,右边的对象通过参数 d 传进来。

可能有同学要问,我怎么没看到operator函数的调用啊,其实dog1.weight+dog2.weight就相当于dog1.operator+(dog2),也就是说所以你写了 dog1 + dog2,operator+ 就被调用了,只是你没看到 .operator+ 这个名字。

对于dog3 = dog1 + dog2的执行流程如下:

  1. 调用 dog1.operator+(dog2);
  2. 函数里创建一个局部对象 Dog dog;
  3. dog.weight = this->weight + d.weight; → 10 + 20 = 30;
  4. return dog; 按值返回一个新对象;
  5. dog3 = 返回值; 调用编译器生成的拷贝赋值,把 weight=30 复制给 dog3。

(7)多态

1.多态的基本概念:

想象一下,你有一个遥控器(可以比作基类的句柄),这个遥控器可以控制不同的电子设备(这些设备可以比作派生类)。无论是电视、音响还是灯光,都能通过遥控器上的“开/关”按钮(这个按钮可以比作一个虚函数)都能控制它们,但具体的操作(打开电视、播放音乐、开灯)则取决于遥控器(虚函数)指向的设备(也就是说你要使用这些设备,你必须得到这些设备的“地址”)。

2.如何实现多态

  1. 使用虚函数(Virtual Function):
    ● 我们在基类中定义一个虚函数,这个函数可以在任何派生类中被“重写”或者说“定制”。
    ● 使用关键字 virtual 来声明。
  2. 创建派生类并重写虚函数:
    ● 在派生类中,我们提供该虚函数的具体实现。这就像是告诉遥控器,“当你控制我的这个设备时,这个按钮应该这样工作”。
  3. 通过基类的引用或指针调用虚函数:
    ● 当我们使用基类类型的指针或引用来调用虚函数时,实际调用的是对象的实际类型(派生类)中的函数版本。

下面我们通过例子理解:

#include <iostream>

using namespace std;

class remote
{
public:
    virtual void button(void)
    {
        cout<<"the remote'button"<<endl;
    }
};

class TV : public remote
{
public:
    void button(void)
    {
        cout<<"the TV be open"<<endl;
    }
};

class light:public remote
{
public:
    void button(void)
    {
        cout<<"the light be open"<<endl;
    }
};

class music:public remote
{
public:
    void button(void)
    {
        cout<<"the music be open"<<endl;
    }
};

int main()
{
    remote BUTTON_test;
    remote *BUTTON;
    TV tv_test;
    light light_test;
    music music_test;
    BUTTON_test.button();
    BUTTON=&tv_test;
    BUTTON->button();
    BUTTON=&light_test;
    BUTTON->button();
    BUTTON=&music_test;
    BUTTON->button();
}

实验数据如下:
在这里插入图片描述

虚函数是 C++ 中用于实现多态(polymorphism)的机制,核心理念就是通过基类去访问派生类定义的函数。简单来说,上面的实例是基类 remote 声明了一个指针 BUTTON,然后通过这个基类指针去访问 TV 类对象、light 类对象和 music 类对象的 button() 函数。前提是基类的 button() 函数必须声明为虚函数;如果不声明为虚函数,基类的指针将访问到基类自己的 button()。

注意:

  • 不加 virtual:看指针/引用的静态类型,remote* 就调 remote::button(),派生类的同名函数只是把基类的“藏”起来了(同名隐藏),并没有参与多态。
  • 加了 virtual:看指针/引用实际指向的对象类型,指向 TV 就调 TV::button(),指向 light 就调 light::button()。

这里还有两个容易踩的坑:

  1. 只有通过基类的指针或引用调用虚函数才会发生多态。 如果直接用一个派生类对象去调用,比如 tv_test.button(),那本来就是调用 TV::button(),跟虚不虚没关系;而如果把派生类对象按值赋给基类对象(remote r = tv_test;),会发生对象切片,派生类那部分成员被切掉,剩下的只是一个纯粹的 remote 对象,再怎么调也不会是多态。
  2. 派生类重写虚函数时,函数签名必须和基类完全一致(函数名、参数列表、返回类型都要匹配,协变返回类型除外)。如果参数列表写得不一致,那就不是重写(override),而是又定义了一个新函数,同样不会参与多态.

另外,只要一个类里含有虚函数,编译器就会给这个类的对象安上一张虚函数表(vtable),对象里则多存一个指向该表的指针(vptr)。运行时通过 vptr 找到 vtable,再按索引取出真正要调用的函数地址——这就是动态绑定的底层实现方式。也正因为如此,虚函数调用比普通函数调用多了一次间接寻址,开销略大一些,所以不是所有函数都适合声明成虚函数。

最后再补充一点:如果一个类会被当作基类使用,并且希望通过基类指针 delete 派生类对象,那么基类的析构函数一定要声明为 virtual,否则 delete 时只会调用基类的析构函数,派生类申请的资源就泄漏了。这一点在前面讲析构函数时也提到过。

同一个函数却有四种不同的“呈现方式”,每个对象都以它们的方式调用同一个函数。这就是多态的魅力——相同的接口,不同的行为。

(8)数据封装

封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全。数据封装引申出了另一个重要的 OOP 概念,即数据隐藏。

数据封装是一种把数据和操作数据的函数捆绑在一起的机制,数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制,C++ 通过创建类来支持封装和数据隐藏(public、protected、private)。

其实我们在上面的学习就已经接触了数据封装。在 C++程序中,任何带有公有和私有成员的类都可以作为数据封装和数据抽象的实例。通常情况下,我们都会设置类成员状态为私有(private),除非我们真的需要将其暴露,这样才能保证良好的封装性。这通常应用于数据成员,但它同样适用于所有成员,包括虚函数。

下面我们以狗类为例,增加一个食物的方法 addFood(int number)。将获得食物的方法设定在 public 下,这样 addFood(int number)方法就暴露出来了,也就是对外的接口。然后我们设置狗类的私有成员(private)食物的份数 total。这是我们第一次使用 private,在这章节里我们也可以学到什么时候该使用 private 什么时候使用 public。total 为获得的食物总数,然后我们还写一个公开的方法 getFood()在 public 下,通过 getFood()来打印出小狗总共获得了几份食物。

#include <iostream>

using namespace std;

class Dog
{
public:
    /*构造函数初始化数据成员*/
    Dog(int i=0)
    {
        total=i;
    }
    void food_add(int x)
    {
        total+=x;
    }
    int get_total(void)
    {
        return total;
    }

private:
    int total;
};

int main()
{
    Dog dog1;
    dog1.food_add(3);
    dog1.food_add(4);
    cout<<"the dog get food total was:" <<dog1.get_total()<<endl;
}

实验数据如下:
在这里插入图片描述
可以看到,类的数据成员 total 被放在了 private 下,因此在类的外部,我们不能通过 dog1.total 这种方式直接访问它。要操作这个数据,只能通过 public 提供的成员函数间接进行:food_add(int x) 负责往 total 里增加食物份数,get_total() 负责把 total 的值返回给外部。

这样,既实现了对 total 数据的正常访问和修改,又避免了类定义的对象直接读写被封装的数据。这就是封装带来的好处:数据被保护起来,外部只能通过我们设计好的接口去操作,使用时更安全、更可控。

补充:上面代码中我们还在构造函数里初始化 total 的数量,不初始化 total 的数量默认是随 int 类型的数。所以我们需要在构造函数里初始化,也体现了构造函数的功能,一般是在构造函数里初始化。不要在类内直接赋值初始化,有可能有些编译器不支持。

(9)抽象

1.数据抽象:

数据抽象的基本概念:

数据抽象是指,只向外界提供关键信息,并隐藏其后台的实现细节,即只表现必要的信息而不呈现细节。数据抽象是一种依赖于接口和实现分离的编程(设计)技术。 数据抽象的好处:

  1. 类的内部受到保护,不会因无意的用户级错误导致对象状态受损。
  2. 类实现可能随着时间的推移而发生变化,以便应对不断变化的需求,或者应对那些要求不改变用户级代码的错误报告。

举个简单的例子,比如我们生活中的手机。手机可以拍照、听音乐、收音等等。这些都是手机上的功能,用户可以直接使用。但是拍照的功能是如何实现的,是怎么通过摄像头取像然后怎么在屏幕上显示的过程,作为用户是不需要知道的。也就是暴露的不用太彻底,用户也不必须知道这种功能是如何实现的,只需要知道如何拍照即可。

就 C++ 编程而言,C++ 类为数据抽象提供了可能。它们向外界提供了大量用于操作对象数据的公共方法,也就是说,外界实际上并不清楚类的内部实现。

其实像 cout 这个对象就是一个公共的接口,我们不必要知道 cout 是如何在屏幕上显示内容的。cout 已经在底层实现好了。

在上一节我们已经学习过数据封装,数据封装是一种把数据和操作数据的函数捆绑在一起的机制,而数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制。

2.接口(抽象类):

接口描述了类的行为和功能,而不需要完成类的特定实现。C++ 接口是使用抽象类来实现的,抽象类与数据抽象互不混淆,数据抽象是一个把实现细节与相关的数据分离开的概念。如果类中至少有一个函数被声明为纯虚函数,则这个类就是抽象类。纯虚函数是通过在声明中使用 “= 0” 来指定的。

设计抽象类(通常称为 ABC)的目的,是为了给其他类提供一个可以继承的合适基类。抽象类里至少包含一个纯虚函数,所以它本身并不是一个完整的、可以直接拿来创建对象的类。

因此,抽象类不能被用来实例化对象,它只能作为接口使用。如果试图直接实例化一个抽象类的对象,编译器会直接报错。

这里也要注意:抽象类和我们前面学的多态是两种不同的思想。多态是通过基类的指针或引用来调用派生类重写后的虚函数,真正创建出来工作的是派生类对象;而抽象类自己不能创建对象,它更像是给派生类“立规矩”,规定派生类必须实现哪些函数接口。

因此,如果一个 ABC 的子类需要被实例化,则必须实现每个虚函数,这也意味着 C++ 支持使用 ABC 声明接口。如果没有在派生类中重写纯虚函数,就尝试实例化该类的对象,会导致编译错误。可用于实例化对象的类被称为具体类。

抽象类的特点

  1. 包含至少一个纯虚函数:
    抽象类至少有一个纯虚函数。这是一种特殊的虚函数,在抽象类中没有具体实现,而是留给派生类去实现。
    纯虚函数的声明方式是在函数声明的末尾加上 = 0。
  2. 不能直接实例化:
    由于抽象类不完整,所以不能直接创建它的对象。就像你不能直接使用“交通工具”的概念去任何地方,你需要一个具体的交通工具。
  3. 用于提供基础结构:
    抽象类的主要目的是为派生类提供一个共同的基础结构,确保所有派生类都有一致的接口和行为。

下面我们通过一个实例来了解:

#include <iostream>

using namespace std;

class Animal
{
public:
    virtual void type(void)=0;
};

class Dog:public Animal
{
public:
    void type(void)
    {
        cout<<"the Animal type is dog"<<endl;
    }

};

class Cat:public Animal
{
public:
    void type(void)
    {
        cout<<"the Animal type is cat"<<endl;
    }

};

int main()
{
    Dog dog1;
    Cat cat1;
    dog1.type();
    cat1.type();
}

实验数据如下:
在这里插入图片描述
可以看到,Animal 在这里就是一个抽象类。它通过纯虚函数 type() 给所有派生类“立规矩”:任何以 Animal 为基类的派生类,都必须实现这个 type() 函数,用来描述自己是什么动物。

Dog 和 Cat 就是两个具体的派生类,它们都按照抽象类 Animal 定下的要求,实现了 type() 函数:Dog 在 type() 里输出自己是 dog,Cat 在 type() 里输出自己是 cat。只有把纯虚函数实现出来,Dog 和 Cat 才算是完整的“具体类”,也才能被正常实例化对象。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值