类的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Date
{
public:
void Init(int year = 1,int month = 1,int day = 1)
{
_year = year;
_month = month;
_day = day;
}

void Print()
{
cout << _year << ":" << _month << ":" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};

抽象数据类型(类)

通过如上代码,我们就在源代码中通过class声明了一个抽象数据类型Date,简称,那么封装一个类有什么好处呢?
好处是类把相关的操作分为两类:

  • 类的设计者:负责考虑类的具体实现,提供类的接口,成员变量等
  • 类的使用者:只关心类提供了哪些功能,而不关心具体实现,从而简化思路

以上面的Date类为例

对设计者

  • 要考虑实现Date,就需要声明成员变量_year _month _day,以及声明及实现成员函数InitPrint

对使用者

  • 只需知道可以调用Date成员函数InitPrint,以及知道它们的用处即可

实例化 – 将类真正投入使用

类也可以用于声明变量,例如Date d就声明了一个变量d,但由于是由声明的,我们将这一过程称为实例化,其中Date这样的抽象数据类型称为,像d这样的变量称为对象

实例化后的对象拥有私有成员变量和整个类公有成员函数,接下来对对象的操作都是对成员变量成员函数的操作

访问成员函数/变量

类的内部

对于类的成员函数,除了显式声明的函数参数外,还有隐式传入的this指针,这是个默认非const修饰的,指向调用该成员函数的对象的指针,编译器可以通过这个指针访问该对象的成员变量成员函数

而我们作为类的设计者,既然语法都隐式地传入this指针了,自然也可以隐式地调用成员,即直接写变量名/函数名调用

当然,手动显式调用this指针也是可以的

Date为例

1
2
3
4
5
6
7
8
9
//该函数声明在Date类中,成员变量见文章开头
void TestPrint()
{
_year = 2024;//隐式调用this访问成员变量
this->_month = 4;//显式调用this
_day = 1;
Print();//隐式调用this来调用成员函数Print()
this->Print();//显式调用this,效果与上一句相同
}

但由于const修饰的对象传出的是const修饰的this指针,普通的this形参无法接收。
那么如何让成员函数传入const修饰的this指针,来使const修饰的对象有成员函数可调用呢?

语法规定,在函数的参数列表(圆括号后面)紧跟一个const可使函数传入const修饰的this指针

这种函数称为常量成员函数

举个例子

1
2
3
4
5
6
//示例代码
//该函数声明在类中
void constPrint() const
{
//....
}

类的外部

和C语言的结构体一样,访问对象内的成员有两种方式

  • 对象名 + . + 成员名 : 用.操作符访问对应成员
  • 对象的指针 + -> + 成员名 : 用->操作符访问指针指向对象的对应成员

Date实例化一个d为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// class Date
// {
// ....
// };

void test1()
{
Date d;
Date* pd = &d;
d.Init(2024,4,1);// . 操作符调用Init成员函数来初始化对象
pd->Print();//->操作符调用Print成员函数来打印内容
}

void test2()
{
Date d;
d._year = 2024;//试图访问成员变量_year,但是访问权限冲突
}
1
2
3
4
//test1输出
2024:4:1
//test2输出
报错,无输出,因为访问权限冲突

代码如上,test1运行的很好,但test2报错了,原因在于test2作为非成员函数访问了访问限定符private控制的成员_year,权限冲突,就会报错。

由此,C++类和对象还有一个重要概念需要强调–访问控制

访问权限控制与封装

使用类和对象编程的一大优点就是类可以封装代码,让使用者只能使用公有的接口和成员变量,而对内部的具体实现不可见,来提高类的易用性安全性

所以C++语法提供了三种访问说明符(access specifiers)

  • public: 该说明符之后的成员在整个程序内可被访问
  • private: 之后的成员仅可被该类的的类域里(如成员函数)访问
  • protected: 一般同private,主要特点体现在类的继承,这里不作讨论

作用范围

某一访问说明符的作用范围开始于它的冒号,终止于下一个访问说明符类的结尾,而类的开始到第一个访问说明符前的访问权限取决于声明类的关键字,分类如下

  • class默认为private权限
  • struct默认为public权限

图例如下

实际上classstruct除了默认权限不一样,基本没有差别

所以为了防止误读,提高可读性,不建议默认区写代码,而是保证每段语句前都有合适的访问限定符

封装

作为类的设计者,一个类按访问权限可以分为两个区

  • public: 将提供给使用者接口(函数)成员变量声明在此,用于外部调用接口和修改非私有的成员函数
  • private: 用于存放受保护成员变量成员函数,防止外部使用者意外恶意调用或修改,造成类的内部结构被破坏等安全问题,所以特别重要的成员变量,和不希望被外部调用的函数声明在这里

例如在声明Date类时,我们将InitPrint接口提供给使用者,用public控制;_year等成员变量不希望被外部随意修改,就用private控制

进阶

学完以上内容,不过是会写个高级点的结构体而已,要写一个完整的类,还需要学习更多的语法知识

构造函数

像本篇的Date类那样显式地调用Init函数来初始化是非常挫的,既然语言本身的内置类型可以在声明的时候初始化,那么类的设计者设计出来的类也应当提供初始化的接口,而支持这一功能的接口便是构造函数

按语法规定,构造函数的函数名必须是类名,没有返回值const修饰的成员变量必须位于初始化列表,其它则可省略。关于初始化列表,稍后详细解释

Date类为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Date
{
public:
//一个普通的构造函数
Date(int year,int month,int day)
{
//进入括号时成员变量已经声明,且未初始化
_year = year;//这是一个赋值操作,而不是初始化
_month = month;
_day = day;
}

private:
int _year;
int _month;
int _day;
}

int main()
{
Date d(2024,4,1);//使用构造函数声明了一个d对象
return 0;
}

以上的构造函数基本能用了,但还有两个问题

  • 构造函数没有初始化成员变量,而是采用赋值操作,无法初始化const修饰的成员变量
  • 使用Date d是会报错的,因为没有提供默认构造函数

对于第一个问题,就要引入初始化列表这一概念,让初始化函数直接拥有初始化成员变量的功能

初始化列表位于构造函数的参数列表之后,花括号之前,以:开头,用,分隔成员变量

Date为例

1
2
3
4
5
6
Date(int year,int month,int day):_year(year) , _month(month) , _day(day) {}
//或者换个书写格式(二者完全等价)
Date(int year,int month,int day):_year(year)
, _month(month)
, _day(day)
{}

通过这样初始化列表,便能在声明对象时,直接初始化成员变量

对于问题二,我们开启另一个个小专题

构造函数的重载和缺省参数

没错,构造函数和函数一样,也是能重载和给参数传缺省值

也就是说我们能写好几个构造函数

下面特别说明几个特殊的构造函数

默认构造函数

原则上对于每一个类,都应该提供有且仅有一个默认构造函数(*多个默认构造函数会报错!*)

而要声明默认构造函数,只需声明无参数构造函数,或者全缺省参数构造函数即可

Date为例

1
2
3
4
5
Date():_year(2024),_month(4),_day(1){}
//====分割线=======
//或者全缺省,两个函数不能同时声明
Date(int year = 2024,int month = 4,int day = 1): _year(year),_month(month),_day(day){}

以上就是两种默认构造函数的声明形式

拷贝构造函数

有时候我们会希望用现有的的对象去初始化一个对象,此时对应的构造函数就称为拷贝构造(函数)

拷贝构造的声明方式为构造函数+参数类型为类本身的引用传参,不加&的话就会死递归报错,有无const皆可,但由于是实现拷贝功能,一般是加const

Date为例

1
2
3
4
5
6
7
8
9
Date(const Date& d):_year(d._year),_month(d._month),_day(d._day){}

//使用示例
Date d(2024,4,1);
Date copy1(d);//调用方式一
Date copy2 = d;//调用方式二,此时不会调用operator=()

copy1 = copy2;//这种并不会调用拷贝构造,而是调用operator=()

使用模板的类的函数缺省值

有时我们在使用类模板来设计类时,需要给模版类类型的形参提供一个缺省值,有些人可能会写个0,但是其实是错的,正确的做法是传一个临时变量

但此时要求模板参数中的类有可用的默认构造函数拷贝构造用于调用

以链表节点Node为例

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class value_type>
struct Node
{
value_type _val;
Node<value_type>* _next;

Node(const value_type& val = value_type()):_val(val),_next(nullptr){}
}

//以用本文的Date实例化为例
Node<Date> node;
//通过输出会发现node中的val已经调用了默认构造函数
node.val.TestPrint();

析构函数

对于声明在栈区静态区的成员函数,程序完全可以自动销毁,
但如果成员变量有指向在堆区声明的某段内存块,在该如果只是仍由程序自动
销毁这个指针,那么那段内存块就会一直处于未释放的状态,也就是造成内存泄漏,
也就是说此时编译器自动生成的析构函数已经不能满足需求,编译器并不知道如何处理声明在堆区上的数据,
这部分操作应由类的设计者来规划

所以我们应当显式地声明一个合理的析构函数

析构函数的函数名也是由语法规定的,为~+类名,并且不能声明形参

以一个指针类为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Ptr
{
public:
Ptr()//构造函数
{
_ptr = new int(1);
}

~Ptr()//析构函数
{
delete _ptr;//手动delte堆区上的数据
_ptr = nullptr;
}

private:
int* _ptr;
}

类的重载操作符

C++语法提供了重载操作符的函数,而由于this指针的存在,在类的内部声明重载操作符函数会稍有不同

-对于一元操作符,[],->之类的重载,不再需要显式传参
-对于二元操作符,+,>之类只需要传右操作数

Date类为例

1
2
3
4
5
6
7
8
//在类的内部,重构一个 ==
public:
bool operator==(const Date& date) const
{
return _year == date._year
&& _month == date._month
&& _day == date._day;
}

小结

至此,C++类和对象已基本入门,再进阶的迭代器,继承,虚继承等将单独出博客。