目录
❤️前言
大家好!今天这篇文章分享的是C++中非常重要的一个特性——类和对象,这个特性使得C++完成了从C面向过程编程到面向对象编程的转变。
正文
1.初步认识面向对象
我们之前学习的C语言是面向过程的编程语言,它关注的是解决问题的过程,我们分析解决问题的步骤,并通过调用一个个函数来解决这个问题。
而C++在C语言的基础上加入了面向对象编程的概念,我们使用C++去解决问题主要依靠的是这个问题中不同对象间的交互。
例如说我们在日常中使用洗衣机去洗衣服,我们可以将人和洗衣机看作两个不同的对象,这两个对象互不相关,只在洗衣服这件事上进行了一系列交互。我们在使用洗衣机时并不关注洗衣机的运作方式,这是洗衣机内部自己设定好的方法,而洗衣机也不会对人造成什么影响,也就是只做了一些简单的交互就完成了这个工作。
可以说,面向对象编程使得编写程序的过程与人们在日常生活中认识接触事物的思维方式更加贴合,更有利与我们使用编程的方式去解决实际的问题。
2.类的引入
当我们使用C语言进行编程时,对于复杂对象,我们使用结构体类型进行描述,我们可以在其中声明描述这个复杂对象的变量,但是在现实生活中,如果要表现一个复杂对象的话只有变量是不够的,因此在C++中我们会发现我们可以在struct中定义函数,这些函数可以描述这个对象的各种行为方式或是可以改变、表现一个对象的各种特征属性。
例如我们用struct创建一个简单的日期结构:
struct Date
{
void Init(int year = 2023, int month = 4, int day = 1)
{
// 可以访问成员变量
_year = year;
_month = month;
_day = day;
}
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
int _year;
int _month;
int _day;
};
当我们这样创建了一个日期结构,那么我们就可以利用它创建一个对象并使用成员访问符访问其中的函数和变量,这是我们根据C语言的风格演化出来的复杂对象定义,而在C++中我们更喜欢使用关键字class来进行代替,定义出的东西我们称之为类。
3.类的定义
我们现在来看看用class来进行类的定义:
class classname// 类名
{
// 可声明:
// 成员函数(类的方法)
// 类的成员变量
}; // 需要注意这个分号
class是定义类的关键字,classname是定义出的类的名称,类中的内容被称为类的成员,类中的变量被称为类的属性或者成员变量,类中的函数被称为类的方法或者成员函数。
类有两种定义方式,第一种定义方式将声明与定义都放在类中定义,这里以日期类作例子:
class Date
{
// 这是访问限定符,下面的内容将会讲到
public:
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
private:
// 为了与外界变量区别开,我们在成员变量名上加入一些特殊的符号以示区别
int _year;
int _month;
int _day;
};
需要注意的是,当我们将函数放在类中定义,那么编译器会将其当作以inline关键字处理过的函数,也就是说它可能会被当作内联函数处理。
第二种方式就是将成员函数的声明和定义分开来,就类似于我们使用C语言编写程序,我们将类的声明放在头文件中,并将成员函数的定义放在源文件中。注意,当我们用这种方式定义成员函数时,应该在函数名之前加上类名和作用域限定符::,继续以日期类为例:
// 头文件中:
class Date
{
public:
void Set(int year = 2023, int month = 4, int day = 1);
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
// 源文件中:
// 函数参数的缺省值只需要在声明中给出
void Date::Set(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
上面两种方式更加推荐第二种方式去定义类,因为我们之前说过指令较长的代码编译器是不会同意它的inline请求的,而且就像我们之前写C语言代码一样,如果将类这种自定义类型写得太长的话,这就不是一种良好的代码风格。如果要利用在其中定义的函数默认会加上inline的特性,我们也可以将长代码函数声明与定义分离,短代码函数直接在类中定义,这种方式也比较推荐。
4.类的访问限定符及封装
C++面向对象有三大特性,分别是封装、继承多态,这里我们首先学习的是三大特性中的封装。在面向对象编程中,我们通过将对象封装起来从而达到让外部成员用户选择性的使用,在这个过程中起到主要作用的就是访问限定符。
C++的访问限定符总共有三个分别是private(私有的)、public(公有的)、protected(受保护的)。这三个限定符的意义分别是:
- public:可以被该类中的函数、子类的函数、友元函数访问,也可以由该类的对象访问;
- protected:可以被该类中的函数、子类的函数、友元函数访问,但不可以由该类的对象访问;
- private:可以被该类中的函数、友元函数访问,但不可以由子类的函数、该类的对象、访问。
目前我们可以认为以public修饰的成员是可以在类外直接被访问的,而private和protected修饰的成员在类外是无法直接访问的。这里需要记住的是,访问限定符的作用从一个限定符到下个限定符出现为止,或者直到遇到 } 也即类的定义结束为止。
注意:定义类时我们可以使用class或者struct这两个关键字,它们在这个地方的区别是class定义出的类成员是默认私有的,而struct定义出的类成员是默认公有的(因为struct同时要兼容C语言的语法)。但是我们在定义类时一般用不到这个性质,我们一般将公有或私有的成员明确地分开来声明。
在了解了访问限定符之后,我们现在来学习封装的相关知识。这里我们首先给出封装的定义:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,只对外公开接口来和对象进行交互。
可以说本质上封装对我们要描述的复杂对象的属性和方法进行了更好的管理,让使用者更好地使用类。将这种行为映射到现实生活中,以洗衣机的例子来说,一台洗衣机由设计者设计出方法和属性并将它们很好的封装起来,内部的各种工作对于我们用户来说是不可见的,但是与我们而言其实无所谓,我们只需要在洗衣机的外壳和各种按键上进行简单的操作即可。
这也是我们在进行面向对象编程时需要做的,也就是通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部的实现细节,并控制那些操作数据的方法可以在类的外部直接可以被用户使用。
5.类的作用域
类指明了一个新的作用域,类的所有成员都在类的作用域中,在类外定义成员时,需要使用::(作用域限定符)来指明成员属于哪一个类域。
就像我们将类成员的声明和定义分开时所做的一样:
// 头文件中:
class Date
{
public:
void Set(int year = 2023, int month = 4, int day = 1);
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
// 源文件中:
// 类的所有成员都在类的作用域中,如果将成员函数的声明与定义分开,那就需要加上作用域限定符
void Date::Set(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
6.类的实例化
以类类型来创建对象的行为称为类的实例化。
就和我们之前在C语言中使用结构体的过程类似,当我们完成一个结构体的定义后,如果要使用它就必须用它来创建出实际的变量。
类的使用也是如此,我们定义出的类类型可以认为是在创造某种事物之前所设计出的图纸。而这个图纸是无法直接作用于现实的,我们需要先根据图纸创建出实际的东西才行。那么对于类来说也是一样,定义类时系统并不会为其分配空间,我们需要先对类进行实例化,产生出该类类型的对象后才能进行一系列的操作。
就拿我们之前定义的Date类来举例子:
class Date
{
public:
void Set(int year = 2023, int month = 4, int day = 1);
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 像这样就可以创建出一个Date类型的临时对象
Date d1;
d1.Set();
d1.Print();
return 0;
}
当我们在栈空间上创建了一个临时对象,我们才真正可以对内存空间中存放的一系列信息进行访问和操作。
7.类对象模型
当我们以类类型进行实例化对象,实例化出的对象也就存在于内存空间中,而类类型与我们之前学过的自定义类型最大的差别就是类类型中可以存在成员函数,那么对象的空间中包含了哪些内容呢?我们该如何计算一个对象的大小呢?
那么我们现在以之前举过的将类类型看作图纸的例子,以图纸创建出一个个不同的对象,它们每一个对象的属性都不一定相同,而它们处理事务的方法却都是一样的,那么如果你是设计者,你会如何去分配属性和方法所占用的空间呢?
思考过后,我们来看看计算机是如何分配类对象中的空间的:一个类中的方法(成员函数)是每个实例化出的对象共同使用的东西,就跟生活中的公共设施一样,它们被存放在一块公共的空间,而并不是存在于具体的实例化对象中,但是每个实例化出的对象的类属性不一定是相同的,因此类的属性在实例化后是真实存在于对象的空间中的。
现在让我们看一些不同的对象的大小:
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1() {}
private:
char _a;
int _b;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{};
用sizeoof观察这三个类的大小,A1的大小为8个字节,而A2和A3都是1个字节大小。那么我们可以发现A1类的大小是延续了C语言结构体的内存对齐的规则和成员变量的大小而决定。而A2和A3都是没有成员变量的类类型,由它们创建出来的对象虽然占用一个字节大小,但是那个字节大小并不代表可操作的数据,而是编译器以此给该类类型的对象进行唯一的标识。
当我们知道了类的大小与内存对齐有关后,现在可以顺便复习一下和内存对齐相关的的知识:
8.this指针
我们先以我们之前所创建的日期类来做例子:
class Date
{
public:
Date(int year = 2023, int month = 4, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Set(int year = 2023, int month = 4, int day = 1);
void Print()
{
std::cout << _year << " " << _month << " " << _day << std::endl;
}
~Date();
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
在类的成员函数中,我们会发现它的特殊性,当它使用类中自带的成员变量时似乎没有进行传参的动作,那么这些成员函数是如何使用类中存在的成员变量的呢?原来C++中引入this指针来解决这个问题。也即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该 指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
- this指针的类型:类类型* const,即在成员函数中,不能给this指针赋值。
- 只能在“成员函数”的内部使用。
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。 所以对象中不存储this指针。
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
- this指针可以在成员函数定义中以类指针的形式显式使用,但是不可以在外部进行显式的传参。
现在让我们想一想下面这段代码的运行结果是什么呢?
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
答案是会正常的运行并打印出相应的结果,因为其实在main函数中使用类指针调用成员函数的过程中,类指针是不会发生解引用的,而只是会隐式的将这个指针值传递给成员函数,如果函数中没有对this指针的解引用,那么也就不会发生运行错误。
🍀结语
今天的文章就到此结束了,感谢大家的阅读,希望大家在各自的生活中都能愉快的过好每一天,天天进步,天天开心!