C++之类和对象(二)

简介: C++之类和对象

类对象模型

计算类的大小

类里面又能写变量又能写函数,那么一个类的大小该如何计算呢?

class Person
{
public:
  void Init()
    {
    }
private:
  char _name[20];
  char _gender[3];
  int _age;
};
int main()
{
  cout << sizeof(Person) << endl;
  return 0;
}

这样一个类的大小是多少呢?如果只有成员变量占空间,那么大小就是28(类也是需要内存对齐的),如果成员函数也要占空间那么就是32(28+4,函数中有多条指令,一般都是存储函数的地址)。事实胜于雄辩,运行一下就能知道结果:

发现,这个类的大小是28也就是说成员函数并没有占空间。其实在设计类存储时有三个方案:

类对象的存储方式

方式一:类中又放成员变量又放成员函数

这种就是将成员变量和成员方法都放在类的空间中,这是最容易想到也是最容易理解的方案,但是这样写的话空间的损失就太大了,虽然函数经过编译后变为一道指令存放在代码段中,通常将函数的第一条指令的地址作为函数的地址,类中也存放的是这个地址,不过一个函数四个字节大小,但是每个对象的变量是不同的,如果按照这种方式存储的话,每实例化一个对象就要保存一份代码,空间损失太大。我想你作为用户来说,肯定也是希望一个应用在保证功能的前提下越小越好,所以这种方案没有被采纳。

方案二:类中放成员变量,找一块区域存放成员函数,并把这个区域的地址存放到类中,可以通过这个区域找到函数。

这种方式在我们看来已经相当不错了,不用再将每个函数的地址都单独存储起来,除了成员变量以外只是多存放了一个地址不过是四个字节而已。但是这种方式在大佬看来还是不够完美,所以还有第三种方法。

方案三:类中只放成员变量,也不放任何地址,将成员函数放到公共代码段,由编译器去查找

【补充】

有没有想过一个空类的大小是多少?空类的大小是零吗?

class A
{
};
int main()
{
  cout << sizeof(a) << endl;
  return 0;
}

运行结果表示空类的大小并不是零字节,而是用一个字节的大小来占位。其实这也是可以预料到的,毕竟如果空类是零字节的话,实例化出来的对象就无法分辨了。其实主要原因是,C++有默认的成员函数,就算我们不写编译器也会自动生成,这个后面会提到。

【结构体内存对齐规则】

  1. 第一个成员在与结构体偏移量为0的地址处。
  2. . 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8
  3. . 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  4. . 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

【几个问题】

  1. 结构体怎么对齐? 为什么要进行内存对齐?

解答:结构体的对齐规则在前面已经说过了。内存对齐明明会造成空间浪费,那么为什么还存在内存对齐?主要有以下几个原因:

1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

2.性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于:为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。所以内存对齐能够提高访问效率。

大家都知道,我们的机器分为32位机器和64位机器,这里的32位和64位其实指的是CPU的位数,而CPU的位数对应着CPU的字长,而字长又决定着CPU读取数据时一次访问多大即空间,即一次读取几个字节,我们以32位机器为例:

总体来说:结构体的内存对齐是拿空间来换取时间的做法

  1. 如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?

解答:我们可以使用 “#pragma pack(num)” 命令来修改VS中的默认对齐数,使用该命令可以将对齐数修改为0以后的任意值。

  1. 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景

解答:大小端是机器针对非单字节的一种存储方式,大端存储是将数据的高位存储在内存的低地址处,小端存储是将数据的低位存储在内存的低地址处。测试机器是大端还是小端只需要取第一个字节就可以判断。

this指针

我们已经知道在类的存储方式上编译器选择了方案三,也就是说我们无论实例化多少个对象,这些个对象用的都是同一份函数。那么问题又来了,既然用的是同一个函数,而且我们也并没有将对象的地址传给函数,函数中也并没有区分对象的方法,那为什么却能输出出不同的结果呢?

class Date
{
public:
  void Init(int year, int month, int day)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  void Print()
  {
    cout << _year << "-" << _month << "-" << _day << " " << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};
int main()
{
  Date d1, d2;
  d1.Init(2022, 10, 28);
  d2.Init(2023, 1, 23);
  d1.Print();
  d2.Print();
  return 0;
}

其实C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成

也就是说上述代码其实长这样:

class Date
{
public:
  void Init(Date *const this,int year, int month, int day)
  {
    this->_year = year;
    this->_month = month;
    this->_day = day;
  }
  void Print()
  {
    cout << this->_year << "-" << this->_month << "-" << this->_day << " " << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};

但是 this 指针参数以及对象的地址都是由编译器自动传递的,当用户主动传递时编译器会报错;不过在成员函数内部我们是可以显示的去使用 this 指针的:

this指针的特性

this指针有如下一些特性:

1.this 指针只能在 “成员函数” 的内部使用;

2.this 指针使用 const 修饰,且 const 位于指针*的后面;即 this 本身不能被修改,但可以修改其指向的对象 (我们可以通过 this 指针修改成员变量的值,但不能让 this 指向其他对象)

3.this 指针本质上是“成员函数”的一个形参,当对象调用成员函数时,将对象地址作为实参传递给 this 形参,所以对象中不存储this 指针;

4.this 指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过建立“成员函数”的函数栈帧时压栈传递,不需要用户主动传递。(注:由于this指针在成员函数中需要被频繁调用,所以VS对其进行了优化,由编译器通过ecx寄存器传递)

相关面试题

1.this指针存在哪里?

解答:this 指针作为函数形参,存在于函数的栈帧中,而函数栈帧在栈区上开辟空间,所以 this 指针存在于栈区上;不过VS这个编译器对 this 指针进行了优化,使用 ecx 寄存器保存 this 指针

2.this指针可以为空吗 ?

解答:this指针作为参数传递时是可以为空的,但是如果成员函数用到了this指针,可能会造成空指针的解引用。

3.下面两段代码的运行结果分别是什么?

//下面两段代码编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A  //代码1
{
public:
    void Print()
  {
    cout << "Print()" << endl;
  }
private:
  int _a;
};
int main()
{
  A* p = nullptr;
  p->Print();
  return 0;
}
//***********************************//
class A  //代码2
{
public:
  void PrintA()
  {
    cout << _a << endl;
  }
private:
  int _a;
};
int main()
{
  A* p = nullptr;
  p->PrintA();
  return 0;
}

解答:代码1可以正常运行,因为虽然我们用空指针A访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器并不会通过类对象p去访问成员函数,即并不会对p进行解引用。而this指针作为参数传递时是允许为空的,在成员函数里也没有对this指针进行解引用。

代码2运行崩溃,因为在成员函数中对this指针进行解引用了,而this指针是一个空指针。

默认成员函数

如果类中什么成员也不写,就称之为空类,空类中真的什么都没有吗?其实并不是,任何类在什么都不写的情况下编译器会自动生成六个默认成员函数。(默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数 )

下面将对这六个默认成员函数进行讲解。

构造函数

基础知识

构造函数是一个特殊的成员函数,名字与类名相同并且不需要写返回类型。构造函数并不需要用户自己调用,而是在创建类类型对象后由编译器自动调用,并且在对象生命周期内只能调用一次。(注意:构造函数虽然叫构造,但它并不是用来给对象开辟空间的,而是在对象实例化以后,给对象初始化用的,相当于Init函数)。

【构造函数的特性】

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载也支持缺省参数
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;
  6. 构造函数对内置类型不处理,对自定义类型调用自定义类型自身的默认构造;
  7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个

以日期类来讲解构造函数的一些特性:

class Date
{
public:
  Date()//无参构造函数
  {
  }
  Date(int year, int month=2,int day=3)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  void Print()
  {
    cout << _year << "-" << _month << "-" << _day << " " << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};
int main()
{
  Date d1;
  //d1.Print();
  Date d2(1949, 1, 03);
  d2.Print();
  return 0;
}

编译成功,也就是说构造函数是支持重载和缺省参数的。但是有一点需要注意的是,当构造函数是无参时,对象后面不要跟括号,否则会产生二义性,也就是说编译器无法确定这个是函数声明还是无参的构造函数。

虽然说构造函数支持重载,但一般只需要显示定义一个全缺省的构造函数即可(选择缺省是因为这个比较灵活有多种调用方式)。

自动生成

构造函数第五点特性提到,如果没有显示定义构造函数,编译器就会自动生成一个无参的默认构造函数。

可以看到,我们不写编译器确实会有一个构造函数来初始化,不过这个初始化出来的数太随机值了,看起来就像乱码一样。这是为什么?这就要用构造函数的第六个特性来解释了;

选择处理

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型 ,**构造函数对内置类型并不处理,而面对自定义类型则会调用自定义类型的构造函数。**解下来看这样一段代码:

class Stack
{
public:
  Stack(int capacity = 4)
  {
    _a = (int*)malloc(sizeof(int) * capacity);
    if (_a == nullptr)
    {
      perror("malloc fail\n");
      exit(-1);
    }
    _top = 0;
    _capacity = capacity;
    cout << "Stack 构造" << endl;
  }
  void Push(int x)
  {
    _a[_top++] = x;
  }
private:
  int* _a;
  int _top;
  int _capacity;
};
class MyQueue
{
public:
  void Push()
  {
    _pushST.Push(2);
  }
private:
  Stack _pushST;
  Stack _popST;
};
class Date
{
public:
  void Print()
  {
    cout << _year << "-" << _month << "-" << _day << " " << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};
int main()
{
  Date d1;
  d1.Print();
  //Date d2(1949, 1, 03);
  //d2.Print();
  Stack st1;
  MyQueue q;
  return 0;
}

日期类都是内置类型,不处理:

Stack栈都是内置类型,但我写了构造函数,所以可以将栈初始化我想要的结果:

MyQueue虽然没写构造函数,但是MyQueue都是自定义类型,会去调用Stack的构造函数,而我写了Stack的构造函数:

其实MyQueue不写构造函数,然后Stack也不写构造函数,最后MyQueue得到的还是随机值,因为最后还是全部都是由内置类型组成的。只要有内置类型就是要写构造函数的。 这样很麻烦,所以到了C++11的时候,大佬们针对这个问题又打了一个补丁,也就是说在声明内置类型的时候可以给一个缺省值。

这个缺省值功能可以说十分强大,甚至可以给定一块空间:

但是这里有一点要注意就是,虽然调用了malloc看起来像是开辟了空间,但其实没有,前面就提到了,类并不会开辟空间相当于一个函数的声明而已,只有在实例化对象的时候才会开辟空间。这里的malloc只是相当于我在设计图纸上标注了某个房间的面积是多大,但是在建造出这个房间之前,这个房间并不会占用任何实际的空间。

默认构造

构造函数的第七个特性是:无参的构造函数和全缺省的构造函数都称之为默认构造函数,并且默认构造函数只能有一个。构造函数虽然可以重载,但是无参和全缺省是不能构成重载的,因为在调用的时候这两种函数都可以不传参会产生二义性。

如果我们写了构造函数,并且不是默认构造函数,那就必须要传参数

相关文章
|
1月前
|
编译器 C++
C++之类与对象(完结撒花篇)(上)
C++之类与对象(完结撒花篇)(上)
34 0
|
6天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
29 4
|
7天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
25 4
|
30天前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4
|
30天前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
23 4
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
53 1
|
1月前
|
编译器 C语言 C++
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
19 1
|
1月前
|
存储 编译器 C语言
【C++打怪之路Lv3】-- 类和对象(上)
【C++打怪之路Lv3】-- 类和对象(上)
16 0