C++之分水岭——类和对象【上】

1. 认识类

在C语言的学习过程中,我们的编程操作都是"面向过程的".例如在学习数据结构"栈"的过程中,我们自己实现了它的各种功能函数,如:初始化,销毁,出栈入栈等等.而面向对象可以说是相对于"栈"的使用者来说的,C++将这些本来需要人们自己造轮子的各种功能封装起来,只给用户对应的接口,用户只需要使用它的接口就能实现各种功能,而这些功能具体是如何实现的用户无需也无法知道.这便是"面向对象".
再以汽车举一个例子:

  • 面向过程:自己造各种零件
  • 面向对象:(驾驶员就是对象)直接通过驾驶室的各种功能键和方向盘,油门,刹车等操作车辆

事实上,C++并不是完全面向对象的语言,也不是十全十美的,但它作为常年雄踞编程语言排行榜前列是有原因的,后续学习中我们会体会到C++的魅力.

2. 引入和使用类

上文以数据结构"栈"和汽车说明面向对象和面向过程的区别,事实上,它们在C++中就是"类"(class).在C语言中,我们使用结构体来自定义类型,但是结构体只能定义变量.而事实上类的行为是不同的.例如汽车会行驶,狗会吃东西.C++兼容了结构体的优点,同时又增加了功能以弥补它的缺点.
C++中一般用class关键字定义一个类,类中可以定义变量,也可以定义函数.

  • 类的实例化:类名+对象名
  • 调用成员函数:对象名.函数名(),视情况传参

2.1 类访问修饰符

面向对象编程需要依靠数据封装实现,通过使用public,privateprotected被称为访问修饰符的关键字标记各个区域,以指定它们被访问的权限.
有效区:从上一个访问修饰符到下一个修饰符之间,或最后一个访问修饰符到最后.

class 类名 {
 
public: 
    
  // 公有成员 
    
private: 
    
  // 私有成员
    
protected: 
    
  // 受保护成员

};

2.1.1 公有(public)成员

被限定访问权限为公有(public)的成员,能在类的外部直接被访问.无需使用函数.

#include <iostream>
using namespace std;
class Date
{
public:
	int _year;
	int GetYear();
	void SetYear(int year);
};
int Date::GetYear()
{
	return _year;
}
void Date::SetYear(int year)
{
	_year = year;
}
int main()
{
	Date date1;//实例化对象
	//通过公有成员函数访问公有成员变量
	date1.SetYear(2021);
	cout << date1.GetYear() << endl;
	//直接访问公有成员变量
	date1._year = 2022;
	cout << date1._year << endl;
	return 0;
}

结果:

2021
2022

公有成员能在类外部直接被访问.C语言中的结构体(struct)如果不加类访问修饰符,默认是public权限,这是符合C的语法的.
类的定义有两种方式:

  • 这里仅将函数的声明放在类中,成员函数的定义放在类外,需要通过类名::函数名的方式定义,类(class)的声明(包括了成员函数的声明)放在头文件(.h)中,成员函数的定义和其他放在源文件(.cpp)中.
  • 将成员函数的声明和定义全部放在类中,这样做是有风险且不规范的,实际工作中都会使用上面的,但有时为了练习的方便会采用这种类的定义方法.这个方法的缺点在于如果成员函数在类的内部定义,编译器有可能会将其视为内联函数处理.

驼峰法命名规范:

  1. 函数名和类名等所有单词首字母大写
  2. 变量首字母小写,后面单词首字母大写
  3. 成员变量首单词前加下划线_

2.1.2 私有(private)成员

私有(private)成员在类的外部是不可访问的.默认情况下,类的所有成员都是私有的,例如下面日期类(Date)的)_month成员.

#include <iostream>
using namespace std;
class Date
{
	int _month;
private:
	int _year;
public:
	void SetYear(int year);
	int GetYear();
};
void Date::SetYear(int year)
{
	_year = year;
}
int Date::GetYear()
{
	return _year;
}
int main()
{
	Date date2;
	//	通过公有成员函数访问私有成员变量
	date2.SetYear(2021);
	cout << date2.GetYear() << endl;
	//直接访问私有成员变量不可行
	//date2._year = 2022;
	return 0;
}

结果:

2021

通过前两个的例子,它们的作用显而易见:使用private封装不想被外界访问的成员,使用public开放访问成员的接口,能提高成员的安全性.
一般的操作是:在私有区域定义数据,在公有区域定义函数,以便能获取数据.

2.1.3 受保护(protected)成员

这个关键字和上一个十分类似,区别在于被protected限定的成员除了不能在类的外部访问之外,还可以被类的子类访问.在学习继承的过程中我们会理解.

2.2 封装

上面的例子提到了"封装",最开始的汽车的例子也体现了封装的思想.

封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全。数据封装引申出了另一个重要的 OOP 概念,即数据隐藏
数据封装是一种把数据和操作数据的函数捆绑在一起的机制,数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制。
–来源于菜鸟教程

总的来说,封装就是将需要保护起来的数据被访问的权限降低,而开放能得到和修改部分数据的接口给用户,以达到最大限度保证数据安全的目的,也降低了用户的使用成本,提高效率.
封装是面向对象的三大特性之一,除此之外,面向对象的特性还有继承和多态.

2.3 类的作用域

作用域影响着编译器在何位置查找对象,即影响着搜索规则,也就是存储属性.

注意区分生命周期和作用域之间的区别.生命周期是对象或变量储存的(物理)位置,根据不同关键字的修饰,它们有可能在栈区,堆区,静态区或常量区.

#include <iostream>
using namespace std;
class Date
{
    int _month;
private:
    int _year;
public:
    void SetYear(int year);
    int GetYear();
};
//需要指明函数所属的域
void Date::SetYear(int year)
{
    _year = year;
}
int Date::GetYear()
{
    return _year;
}

就如上面例子中在类外部定义的成员函数,必须指定函数所属的类区域.

2.4 类的实例化

一个类就像一个房子的图纸,类的实例化就是照着图纸造出来的毛坯房,通过各种接口,可以访问或修改成员变量,这就相当于给房子装修.换句话说,类就是一个模板,通过它能创造出任意个实例.
类的实例化上面已经出现了

#include <iostream>
using namespace std;
class Person
{
public:
    int age;
};
int main()
{
    Person xiaoming;
    xiaoming.age = 18;
    cout << xiaoming.age << endl;
    return 0;
}

结果:

18

区分声明和定义:

  • 函数:看有无分号和函数体即可,很好判断
  • 变量:有开辟实际物理空间就是声明,否则是定义.类中的成员变量就是声明,因为它们没有占用物理空间

声明和定义的区别用上面图纸和房子的例子也好理解:声明就是图纸,不能住人;实例化的对象是房子,可以住人
一个经常被错误声明造成的错误:链接冲突

多个.cpp文件都包含同一个头文件(.h),这个头文件中有一个全局变量

//test.h
int a;
//test.cpp
#include "test.h"
//main.cpp
#include "test.h"


//报错
duplicate symbol '_a' in:
    main.cpp.o
    test.cpp.o

在编译后产生的.o文件中,每个变量都是有别名的,它们的名字都存放在符号表中.但是全局变量是存放在静态区的,两个.cpp文件访问的都是同一个变量a,这样是违反规则的,无法让变量进入符号表,也就找不到变量a.
修正方法:

  • extern关键字:声明,告诉编译器这是外部的变量.
  • static关键字:修改全局变量的链接属性,使之当前文件可见,即进入符号表.换句话说,全局变量是所有文件可见的,在内存上是同一个,但是静态变量不是同一个.

3. 类和对象的特点

3.1 对象存储方式

类在结构体的基础上取长补短,储存方面除了普通的内置类型成员变量还有自定义的成员函数,如何才能在不同情况下达到利用最大化呢?

3.1.1. 变量和函数都放在一个类中

理论上实例化多个对象各自的成员变量和成员函数在物理上都是独立存在的,因为对象是类的实例化.通过打印地址可验证.
调用函数的操作,函数在编译链接时就已经被链接器通过函数名找到了(地址),而不是在运行时被找到.
这种办法也就只有缺点了,那就是占用空间,明明函数的功能是一样的,实例化一个对象就要多展开一次代码,降低了代码的复用性.下面通过打印地址来看编译器是否采取了这种方法.

#include <iostream>
using namespace std;
class Date
{
    int _month;
public:
    int _year;
public:
    void  static Print();
};

void Date::Print()
{
    cout << "Date::Print" << endl;
}
int main()
{
    //实例化两个对象,初始化它们的成员
    Date Date3;
    Date Date4;
    Date3._year = 2021;
    Date4._year = 2022;
    //打印一下成员变量和成员函数的地址
    Date3.Print();
    Date4.Print();
    cout << &Date3._year << endl;
    cout << &Date4._year << endl;
    return 0;
}

结果:

Date::Print
Date::Print
0x16d713708
0x16d713700
由于C++不方便查看成员函数的地址,所以通过汇编查看两次调用成员函数的地址.image.png
结果证明,成员变量确实是独立开来的,但是成员函数却是同一个

3.1.2. 将函数的地址存放在一张表中

成员变量存放在类中,成员函数存放在其他地方,然后将它们的地址放在表中.这样只需存一份表,就可直接访问地址,使用了函数指针.特殊情况下会使用此方法,比如多态.

3.1.3. 把成员函数放在公共代码区

实际上,这就是编译器采取储存对象成员的方法(除特殊情况外).
如何理解公共代码区:公共代码区就是字面意思,编译器有时候会将频繁使用的函数代码(二进制)存放在这个区域,只要需要就在这里面取即可,无需再生成一次代码,提高复用性.
下面验证结论,实例化一个对象,将它指向nullptr,然后通过它访问成员函数,如果运行超过,则说明编译器采用的是这个方法–成员函数不存放在对象中(物理)

#include <iostream>
using namespace std;
class Date
{
    int _month;
public:
    int _year;
public:
    void  static Print();
};

void Date::Print()
{
    cout << "Date::Print" << endl;
}
int main()
{
    Date* date1;
    date1 = nullptr;
    (*date1).Print();
    return 0;
}

结果:

Date::Print

结果证明了编译器采取的是第三种方法.

3.2 类的大小

类作为C++的新特性,继承了C的结构体.结构体有计算大小的规则,类也不例外.

#include <iostream>
using namespace std;
//类既有成员变量也有成员函数
class A1
{
public:
    void f1(){};
private:
    int _a;
};
//类有成员函数
class A2
{
public:
    void f2(){};
};
//空类
class A3
{};
int main()
{
    cout << sizeof(A1) << endl;
    cout << sizeof(A2) << endl;
    cout << sizeof(A3) << endl;
    return 0;
}

结果:

4
1
1

通过上面的结果结合对象的储存方式看,类的大小只与类成员变量的大小有关(除了平台),和成员函数无关.原因自然是成员函数并不储存在类中,当然类的大小不会把成员函数算进去.
值得注意的是空类的大小是1.通过查阅资料,我总结了以下几点:

  • 类是模板,根据它实例化是要开辟内存空间的,开辟内存空间至少需要1个字节(至少大佬是这么规定的)
  • 避免sizeof(空类)出现分母为0的情况
  • 实际上类实例化时栈帧开辟内存空间也是有内存对齐规则的

复习结构体内存对齐规则

结构体或类的数据储存在内存中是在函数栈帧被开辟之后进行的,内存对齐能够合理高效地利用内存.

4. this指针

在C++中,this指针是所有成员的隐含参数,每个对象都能通过this访问自己的地址.因此它常被用来在成员函数中特指对象中的成员变量.
show you the code.

为了阅读和举例的方便,将成员函数定义在类的内部

#include <iostream>
using namespace std;
class Date
{
public:
    void SetFunc(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void GetFunc()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }

private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    Date date;
    date.SetFunc(2022, 8, 2);
    date.GetFunc();
    return 0;
}

结果:

2022/8/2

这段代码定义了Date类,有年月日三个私有成员变量,两个公有成员函数.Set函数是给对象的成员变量赋值,Get函数是获取成员变量(通过打印代替).
在Set函数中,看起来是year赋值给year,这是好像在做无用功.
实际上在编译器眼中它们是这样的:

class Date
{
public:
    void SetFunc(int year, int month, int day)
    {
        this->year = year;
        this->month = month;
        this->day = day;
    }
    void GetFunc()
    {
        cout << this->year << "/" << this->month << "/" << this->day << endl;
    }

private:
    int year;
    int month;
    int day;
};

this是一个常量指针classname* const this,它被const修饰,它存放的地址(它的值)是被允许修改的.当我们调用某个对象的成员函数时,实际上是由this替对象调用的成员函数.对于我们而言,this是隐式定义的,它隐式地指向对象,保存着对象的地址.
由一段伪代码理解this保存的对象的地址,假如类是上面的Date:

Date date;
date.GetFunc();

实际上通过对象调用Get函数是将对象date的地址传给this指针,由this指针找到对象的成员函数.

Date::GetFunc(&date)

一个对象也可以被认为是一个类的成员,因此在类域传入成员的地址可以找到该对象.上面的等价操作就是在编译时,编译器会在成员函数中成员变量前都加上this->.通过对象调用成员函数,**第一个形参(隐藏)**会传入对象的地址.

特点:

  • this指针不能在形参和实参的位置加入,这是不合法的.但是可以在成员函数内部使用.
  • 如果将this指针置为空,不使用它则不会运行崩溃.
  • this指针在栈区,因为它是形参.本质上是成员函数的形参,通过对象调用成员函数,实际上是把地址传给this指针,通过this指针调用成员函数,所以对象是不储存this指针的.
  • visual studio传递this指针,通过ecx寄存器传递,提高访问变量的效率.原因是代码中有多个需要使用this指针访问的成员变量.因此不需要用户主动传递this指针,因为它本来就存在.
  • 区分运行崩溃和编译报错.编译阶段是检查语法;运行崩溃是在没有编译错误的基础上运行后产生的逻辑错误,如空指针,越界等.
  • this被const修饰,它不能被修改,但是它指向的内容可以修改.复习const
  • 传递指针通过寄存器或其他,取决于编译器(IDE).
    复习const关键字修饰指针

问题:

  1. this指针可以为空吗
  2. this指针储存在何处
  3. 下面程序编译运行之后的结果是?
//a.编译报错 b.运行崩溃 c.正常运行
#include <iostream>
using namespace std;
class A
{
public:
    void Print()
    {
        cout << "Print()" << endl;
    }
private:
    int _a;
};
int main()
{
    A* p = nullptr;
    p->Print();
    return 0;
}

结果:

Print()
选c

原因是成员函数并不储存在类中.即使p是空指针,然而并未对空指针进行解引用操作,因此不会编译报错,更不会运行崩溃.

  1. 下面程序编译运行之后的结果是?
//a.编译报错 b.运行崩溃 c.正常运行
#include <iostream>
using namespace std;
class A
{
public:
    void PrintA()
    {
        cout << _a << endl;
    }
private:
    int _a;
};
int main()
{
    A* p = nullptr;
    p->PrintA();
    return 0;
}

结果:

b.运行崩溃,因为空指针问题

请注意函数中是直接打印_a.通过p调用成员函数,实际上是对指针解引用,编译时发现不了,运行时崩溃.

5. 补充

在使用类需要传参的成员函数时,常常与缺省参数搭配使用,这样让传参个数更灵活.
例如:

#include <iostream>
using namespace std;

class Date
{
public:
    void SetFunc(int year = 2000, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void GetFunc()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }

private:
    int _year = 1;
    int _month = 1;
    int _day = 1;
};
int main()
{
    Date date;
    //还未调用Set函数就直接打印
    date.GetFunc();
    //不传参
    date.SetFunc();
    date.GetFunc();
    //传参
    date.SetFunc(2002);
    date.GetFunc();
    
    date.SetFunc(2003,2);
    date.GetFunc();
    
    date.SetFunc(2004,3,2);
    date.GetFunc();
    return 0;
}

结果:

1/1/1
2000/1/1
2002/1/1
2003/2/1
2004/3/2

具有初始化成员变量值的功能的函数,常常使用缺省参数以增加传参方式的数量.另外在定义类的成员变量的同时也是可以声明它的值的(注意区分声明和定义),通过第一次打印就可以知道成员变量声明的值就是对象实例化后成员变量的默认值.

6. C++的优越性

C++和C最大的不同在于其面向对象的思想,就拿之前用C实现数据结构栈(Stack)来讲:

  • C语言只能用结构体自定义类型,但是结构体中只能使用变量(内置或自定义),不能将函数也放进去,所以用户要用C实现一个数据结构栈,不仅需要自定义栈类型,还要独立地实现各种功能,如初始化/销毁,压栈/出栈等等,这无疑增大了用户使用工具的成本(虽然可以存一份副本,有需要就取).而且随着工作量越来越庞大,用C显然不那么高效.
  • C++继承了结构体,并将其发扬光大,它允许用户将各种功能函数放进类中,让方法作为类的一部分,用户无需考虑功能是如何实现的,只需要知道接口的功能和传参即可.所以各种工具是内置在C++编译器中的,用户只需要调用即可.

C++的优越性远不如此,在后续的学习过程中我们将领略它的魅力.


8/3/2022
posted @ 2022-12-06 22:31  shawyxy  阅读(36)  评论(0编辑  收藏  举报