【C++】类和对象(上)

简介: C++面向对象编程入门、类和对象的初步认识。

1. 面向过程和面向对象的初步认识

C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。

image.png
image.png

C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

image.png
image.png

面向对象的思维方式,它更加注重事情有哪些参与者,需求里面有哪些对象,这些对象各自需要做些什么事情。将其拆解成一个个模块和对象,这样会更易于维护和拓展。


2. 类的引入

C语言结构体中只能定义变量,在C++中,结构体不仅可以定义变量,也可以定义函数。 比如:之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现,会发现struct中也可以定义函数。

struct Stack
{
   
   
    // 成员函数
    void Init(int n = 4)
    {
   
   
        a = (int*)malloc(sizeof(int)* n);
        if (nullptr == a)
        {
   
   
            perror("malloc申请空间失败");
            return;
        }

        capacity = n;
        size = 0;
    }

    void Push(int x)
    {
   
   
        //...
        a[size++] = x;
    }

    // 成员变量
    int* a;
    int size;
    int capacity;
};

在这里可以看到我们可以在结构体中定义函数,之前我们在学习C语言数据结构时,结构体中只能定义变量,而在C++中,将结构体升级成了类,类可以将成员属性(变量)和成员方法(函数)封装在一起。 当然,C++中可以直接使用stack表示类,前面不需要再加struct关键字,但因为C++是兼容C的,所以我们使用C语言的写法定义变量也是没问题的。那么,究竟什么是类呢?


3. 类的定义

类是对现实生活中一类具有共同特征的事物的抽象。如果一个程序里提供的数据类型与应用中的概念有直接的对应,这个程序就会更容易理解,也更容易修改。类的内部封装了属性和方法,用于操作自身的成员。类是对某种对象的定义,具有行为(behavior),它描述一个对象能够做什么以及做的方法(method),它们是可以对这个对象进行操作的程序和过程。它包含有关对象行为方式的信息,包括它的名称、属性、方法和事件。

class className
{
   
   
    // 类体:由成员函数和成员变量组成
};  // 一定要注意后面的分号

class为定义类的关键字ClassName类的名字{}中为类的主体,注意类定义结束时后面分号不能省略

类体中内容称为类的成员类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数

💕 类的两种定义方式:

1. 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
class Date
{
   
   
public:
    // 定义
    void Init(int year, int month, int day)
    {
   
   
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;  // 声明
    int _month;
    int _day;
};
2. 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::

image.png

💕 成员变量命名规则的建议:

// 我们看看这个函数,是不是很僵硬?
class Date
{
   
   
public:
    void Init(int year,int month,int day)
    {
   
   
        // 这里的year,month,day到底是成员变量,还是函数形参?
        year = year;
        month = month;
        day = day;
    }
private:
    int year;
    int month;
    int day;
};

由于类中可以同时定义变量和函数,但是函数的形参和类的成员变量名字可能会发生冲突,所以就会对我们的的初始化赋值产生影响,当然我们可以使用类名+域作用限定符或者this指针来解决这个问题,但是毕竟比较麻烦。

class Date
{
   
   
public:
    void Init(int year, int month, int day)
    {
   
   

        Date::year = year;
        Date::month = month;
        this->day = day;
    }
private:
    int year;
    int month;
    int day;
};

由于使用this指针或者域作用限定符比较麻烦,所以在C++中我们常常使用一个_+成员变量来表示类中的成员变量。当然还有其他的方式来表示。比如:year_、y_year以及yYear等小驼峰法。在实际的开发过程中一般看公司的要求,一般都是加个前缀或者后缀标识区分就行。

class Date
{
   
   
private:
    int _year;
    int _month;
    int _day;
};

4. 类的访问限定符及封装

4.1 访问限定符

C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。

C++的访问限定符有三种:

image.png

💕 访问限定符说明

  • public修饰的成员在类外可以直接被访问
  • protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
  • 访问权限作用域==从该访问限定符出现的位置开始直到下一个访问限定符出现时为止==
  • 如果后面没有访问限定符,作用域就到 }即类结束。
  • class的默认访问权限为private,struct为public(因为struct要兼容C)

注意: 访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别

【面试题】

问题:C++中struct和class的区别是什么?

解答:C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类默认访问权限是private。注意:在继承和模板参数列表位置,struct和class也有区别,后序我们详细介绍。


4.2 封装

【面试题】

面向对象的三大特性:封装、继承、多态。在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?

封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。

封装本质上是一种管理,让用户更方便使用类。 比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。

image.png

对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。

在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。

例如:C++语言中一般都会使用private修饰类的成员变量, 用户不能直接访问类中的数据,只能通过特定的接口来操作对象。这避免了我们在C语言中直接访问结构体的成员变量的所带来的风险。


5. 类的作用域

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。

class Person
{
   
   
    public:
    void PrintPersonInfo();
    private:
    char _name[20];
    char _gender[3];
    int _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
   
   
    cout << _name << " "<< _gender << " " << _age << endl;
}

这里我们需要注意的是:在类中定义的变量仅仅只是声明,并没有为变量真正开辟空间,只有当我们使用类实例化一个对象时,才会真正为类中的成员变量开辟空间。


6. 类的实例化

用类类型创建对象的过程,称为类的实例化

  • 类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。
  • 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量。

做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。

image.png
image.png


7. 类对象模型

7.1 如何计算对象的大小

以前我们在学习C语言的时候,可以通过结构体的内存对齐规则来计算一个结构体的大小,但结构体中只有成员变量,类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?

class A
{
   
   
public:
    void PrintA()
    {
   
   
    cout<<_a<<endl;
    }
private:
    char _a;
};

7.2 类对象的存储方式

(1) 对象中包含类的各个成员

image.png

缺陷: 每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。那么如何解决呢?

(2) 代码只保存一份,在对象中保存存放代码的地址

image.png

如果按照这种方式存储,那么当一个类创建多个对象的时候,每个对象都要保存一份函数表,也不是最优的设计方式。

(3) 只保存成员变量,成员函数存放在公共的代码段

image.png

我们可以通过下面的例子来验证我们的结论:

image.png

这里我们可以发现当类中仅有成员函数或者是一个空类时,类的大小是1,那么我们可以初步猜想类中的成员函数是不被纳入类的大小所占的空间的。所以类中只存储了成员变量的地址,而并没有存储成员方法的地址。这里我们就可以显而易见的得出结论:类对象的存储方式是==类中只保存成员变量,成员函数存放在公共的代码段。==

💕 那么为什么空类的大小是1而不是0呢?

其实这是因为当我们使用一个空类实例化对象时,编译器给了空类一个字节来唯一标识这个类。占位,不存储有效数据,标识对象存在。

7.3 结构体内存对齐规则

  • 第一个成员在与结构体偏移量为0的地址处。
  • 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8
  • 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  • 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
【面试题】
  1. 结构体怎么对齐? 为什么要进行内存对齐?
  2. 如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
  3. 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景。

如果大家对以上三道题目还不是很理解的话可以看我之前的一篇博客【自定义类型详解】


8. this指针

8.1 this指针的引出

首先我们先来定义一个日期类 Date

image.png

对于上述类,有这样的一个问题:

Date类中有 InitPrint 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?

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

image.png

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; // 日
};
int main()
{
   
   
    Date d1,d2;
    d1.Init(&d1,2022, 2, 2);
    d1.Print();
    d2.Init(&d2,2023, 2, 3);
    d2.Print();
    return 0;
}

8.2 this指针的特性

  • this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
  • 只能在“成员函数”的内部使用。
  • this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
  • this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
【面试题】

1. this指针存在哪里?

this指针一般在非静态的成员函数的函数栈帧里面,而VS是通过ecx寄存器来传递的,以便提高效率。

2. this指针可以为空吗?

下面我们先来看一段代码:

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

image.png

这段代码是可以运行成功的,虽然说空指针p访问了成员函数Print,但是成员函数并不在对象中,而是在公共的代码段中,所以编译器并不会通过对象去访问成员函数,也就不存在对空指针p的解引用。

下面我们再来看另外一段代码:

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

image.png

这里我们发现程序运行会发生奔溃,这是因为在Print内部,使用this指针解引用了_a, 而此处的对象是一个空指针,就引发了空指针的解引用。

所以,this指针作为参数传递的时候是可以为空的,但是如果成员函数内部使用到了this指针,那就会造成空指针的解引用。


9. C语言和C++实现Stack的对比

1. C语言实现
typedef int DataType;
typedef struct Stack
{
   
   
    DataType* array;
    int capacity;
    int size;
}Stack;
void StackInit(Stack* ps)
{
   
   
    assert(ps);
    ps->array = (DataType*)malloc(sizeof(DataType) * 3);
    if (NULL == ps->array)
    {
   
   
        assert(0);
        return;
    }
    ps->capacity = 3;
    ps->size = 0;
}
void StackDestroy(Stack* ps)
{
   
   
    assert(ps);
    if (ps->array)
    {
   
   
        free(ps->array);
        ps->array = NULL;
        ps->capacity = 0;
        ps->size = 0;
    }
}
void CheckCapacity(Stack* ps)
{
   
   
    if (ps->size == ps->capacity)
    {
   
   
        int newcapacity = ps->capacity * 2;
        DataType* temp = (DataType*)realloc(ps->array,
            newcapacity * sizeof(DataType));
        if (temp == NULL)
        {
   
   
            perror("realloc申请空间失败!!!");
            return;
        }
        ps->array = temp;
        ps->capacity = newcapacity;
    }
}
void StackPush(Stack* ps, DataType data)
{
   
   
    assert(ps);
    CheckCapacity(ps);
    ps->array[ps->size] = data;
    ps->size++;
}
int StackEmpty(Stack* ps)
{
   
   
    assert(ps);
    return 0 == ps->size;
}
void StackPop(Stack* ps)
{
   
   
    if (StackEmpty(ps))
        return;
    ps->size--;
}
DataType StackTop(Stack* ps)
{
   
   
    assert(!StackEmpty(ps));
    return ps->array[ps->size - 1];
}
int StackSize(Stack* ps)
{
   
   
    assert(ps);
    return ps->size;
}

int main()
{
   
   
    Stack s;
    StackInit(&s);
    StackPush(&s, 1);
    StackPush(&s, 2);
    StackPush(&s, 3);
    StackPush(&s, 4);
    printf("%d\n", StackTop(&s));
    printf("%d\n", StackSize(&s));
    StackPop(&s);
    StackPop(&s);
    printf("%d\n", StackTop(&s));
    printf("%d\n", StackSize(&s));
    StackDestroy(&s);
    return 0;
}

可以看到,在用C语言实现时,Stack相关操作函数有以下共性:

  • 每个函数的第一个参数都是Stack*
  • 函数中必须要对第一个参数检测,因为该参数可能会为NULL
  • 函数中都是通过Stack*参数操作栈的
  • 调用时必须传递Stack结构体变量的地址

结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。

1. C++实现
typedef int DataType;
class Stack
{
   
   
public:
    void Init()
    {
   
   
        _array = (DataType*)malloc(sizeof(DataType) * 3);
        if (NULL == _array)
        {
   
   
            perror("malloc申请空间失败!!!");
            return;
        }
        _capacity = 3;
        _size = 0;
    }
    void Push(DataType data)
    {
   
   
        CheckCapacity();
        _array[_size] = data;
        _size++;
    }
    void Pop()
    {
   
   
        if (Empty())
            return;
        _size--;
    }
    DataType Top() {
   
    return _array[_size - 1]; }
    int Empty() {
   
    return 0 == _size; }
    int Size() {
   
    return _size; }
    void Destroy()
    {
   
   
        if (_array)
        {
   
   
            free(_array);
            _array = NULL;
            _capacity = 0;
            _size = 0;
        }
    }
private:
    void CheckCapacity()
    {
   
   
        if (_size == _capacity)
        {
   
   
            int newcapacity = _capacity * 2;
            DataType* temp = (DataType*)realloc(_array, newcapacity *
                sizeof(DataType));
            if (temp == NULL)
            {
   
   
                perror("realloc申请空间失败!!!");
                return;
            }
            _array = temp;
            _capacity = newcapacity;
        }
    }
private:
    DataType* _array;
    int _capacity;
    int _size;
};
int main()
{
   
   
    Stack s;
    s.Init();
    s.Push(1);
    s.Push(2);
    s.Push(3);
    s.Push(4);
    printf("%d\n", s.Top());
    printf("%d\n", s.Size());
    s.Pop();
    s.Pop();
    printf("%d\n", s.Top());
    printf("%d\n", s.Size());
    s.Destroy();
    return 0;
}

C++中通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限可以控制那些方法在类外可以被调用,即封装,在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。而且每个方法不需要传递Stack的参数了,编译器编译之后该参数会自动还原,即C++中`Stack `参数是编译器维护的,C语言中需用用户自己维护。


相关文章
|
3天前
|
存储 编译器 程序员
c++存储类
c++存储类
19 3
|
3天前
|
C++
c++类&对象
c++类&对象
16 3
|
7天前
|
C++
C++中的对象
C++中的对象
19 2
|
7天前
|
C++
C++派生类
C++派生类
18 0
|
7天前
|
存储 程序员 数据安全/隐私保护
C++类
C++类
18 0
|
1天前
|
安全 程序员 C语言
从C语言到C++_37(特殊类设计和C++类型转换)单例模式(下)
从C语言到C++_37(特殊类设计和C++类型转换)单例模式
12 5
|
1天前
|
设计模式 编译器 Linux
从C语言到C++_37(特殊类设计和C++类型转换)单例模式(中)
从C语言到C++_37(特殊类设计和C++类型转换)单例模式
7 0
|
1天前
|
安全 编译器 C语言
从C语言到C++_37(特殊类设计和C++类型转换)单例模式(上)
从C语言到C++_37(特殊类设计和C++类型转换)单例模式
7 0
|
3天前
|
编译器 C++
C++的基类和派生类构造函数
在 C++ 中,类的构造函数不能被继承,但基类的普通成员函数可以在派生类中访问。派生类必须通过其构造函数初始化继承的成员变量,由于私有成员变量无法直接初始化,因此需要在派生类构造函数中调用基类的构造函数来完成。示例代码显示了如何在派生类构造函数中调用基类构造函数,确保正确初始化。构造函数的调用顺序遵循自顶向下、从基类到派生类的规则,且只能调用直接基类的构造函数。如果基类没有默认构造函数,而派生类未指定构造函数调用,会导致编译错误。
18 4
|
5天前
|
存储 程序员 数据安全/隐私保护
深入探索C++中的类与对象
深入探索C++中的类与对象