文章目录
9.面向对象—成员函数篇
所以在上一篇里我们已经做好了对象的产生与消亡,接下来就得让这个类来帮我们做点什么事情了。
(1). const函数
上一节中我们提到了拷贝构造函数,它的一个基本形式是这样的:
Person(const Person& per);
类的完整定义如下:
#include <iostream>
#include <string>
using namespace std;
class Person
{
private:
string name;
string ID;
public:
Person() = delete;
Person(const string& _name, const string& _ID)
: name(_name), ID(_ID) {}
Person(const Person& per)
{
name = per.getName();
ID = per.getID();
}
const string& getName()
{
return name;
}
const string& getID()
{
return ID;
}
};
int main()
{
Person p1{"Voltline", "1234567"};
return 0;
}
这段代码看着挺正常的,不过如果我们一旦选择编译就会报错,比如这样:
错误中提示了一个很重要的事情,我们在构造函数里传入的per是一个const Person&,这会有什么问题呢?假设我们给这个类加上下面这个成员函数:
void Person::NewName(const string& name)
{
this->name = name;
}
理论上说,如果前面那段代码能正常运行,这个函数也应该是可以正常调用的,因为这俩函数没有什么本质区别嘛,虽然类型之类的不一样,但都是正常的成员函数。
这三者不能调用的真正原因是:没有对函数进行类型限定,通过这个函数是可以对类进行修改的,即便这个对象的属性是私有的,通过一部分公有成员函数还是可能对对象进行修改,这和传入参数的const显然就有冲突了,因此C++加入了成员函数后置的const关键字,用于确保某个成员函数不会对类的属性进行修改,所以我们得把函数改成下面这个样子:
#include <iostream>
#include <string>
using namespace std;
class Person
{
private:
string name;
string ID;
public:
Person() = delete;
Person(const string& _name, const string& _ID)
: name(_name), ID(_ID) {}
Person(const Person& per)
{
name = per.getName();
ID = per.getID();
}
const string& getName() const
{
return name;
}
const string& getID() const
{
return ID;
}
};
int main()
{
Person p1{"Voltline", "1234567"};
return 0;
}
于是编译顺利通过了,那如果我还想用同一个名字的函数,它返回属性的引用用于修改呢?可以的:
string& getName()
{
return name;
}
string& getID()
{
return ID;
}
const string& getName() const
{
return name;
}
const string& getID() const
{
return ID;
}
这样的两对函数可以构成重载的,我们是可以这么写的,作为一个好习惯,对于没有修改属性需求的成员函数,其实都可以在函数的最后加上const进行限定,例如:
template<typename T>
class vector
{
private:
T* _arr;
size_t _size;
public:
...
size_t size() const { return _size; }
};
很显然,我们没有通过size()方法修改一个vector中的size的需求,而const函数本身也是可以被任何类型的对象调用的。
上一节中没有提到const函数主要还是因为只涉及到了构造函数和析构函数,暂时还没有一般的成员函数。不过还有一个点:如果成员是私有的,在构造函数里一定要通过类似getID这样的函数访问吗? 其实是不需要的,例如:
class Person
{
private:
string name;
string ID;
public:
Person() = delete;
Person(const string& _name, const string& _ID)
: name(_name), ID(_ID) {}
Person(const Person& per)
: name(per.name), ID(per.ID) {}
};
编译一切正常,在C++中,某个类的参数表如果包含同类的对象,那么在函数中可以直接访问该对象的私有属性,在这个情况之下我们就不需要再去重新写一堆getter函数(用于获取某个属性的值的函数)了。
(2). 类到底能装哪些东西
#1.using语句
我们一直到现在写的类中都只有变量、常量和函数,作为一个类,我们应该能够控制更多东西,比如我们可以这么写:
class MyIntVector
{
public:
using size_type = size_t;
using type = int;
using reference = type&;
using const_reference = const type&;
using pointer = type*;
private:
pointer _arr;
size_type _size;
public:
...
};
我们可以在类中使用using语句给一系列数据类型起别名,如果你看过STL实现的代码,例如MSVC的vector实现中就有这么一段:
template <class _Ty, class _Alloc = allocator<_Ty>>
class vector { // varying size array of values
private:
...
public:
...
using value_type = _Ty;
using allocator_type = _Alloc;
using pointer = typename _Alty_traits::pointer;
using const_pointer = typename _Alty_traits::const_pointer;
using reference = _Ty&;
using const_reference = const _Ty&;
using size_type = typename _Alty_traits::size_type;
using difference_type = typename _Alty_traits::difference_type;
...
现在的你可能不能看懂这段代码,没关系,我们能够看到这下面一系列using语句,在在定义了这个类的文件中我们可以直接使用MyIntVector::pointer作为类型使用,虽然看起来好像没什么必要,但是在写代码中使用这些别名可以让代码的逻辑更加明晰。
#2.内部类
很神奇的,我们还可以在类内定义一个新的类:
class MyIntVector
{
public:
class iterator
{
public:
int* _beg;
};
public:
using size_type = size_t;
using type = int;
using reference = type&;
using const_reference = const type&;
using pointer = type*;
private:
pointer _arr;
size_type _size;
public:
...
};
那么接下来就有一个问题,iterator作为MyIntVector的内部类,MyIntVector能不能访问iterator的私有属性呢?看起来这个内部类好像是 “属于” 外部类的,那实际上真的是这样吗?我们简化一下上述代码:
class A
{
public:
class B
{
private:
int a;
public:
B() : a(0) {}
B(int c) : a(c) {}
};
private:
B b;
public:
A() : b(12) {}
int get()
{
return b.a;
}
};
B是A内部的一个类,B中有一个私有属性a,并且定义了两个构造函数,在类A中有一个B类型的私有属性b,有一个get方法返回对象b的属性a,我们来试着编译运行一下:
很好,编译出错了,这样一来我们就知道了,虽然B的位置在A里面,但是实际上A还是不能直接访问B的属性。这个问题其实也是有解决方案的,我们在后面会提到友元,我们可以在类B中加一行:
friend class A;
这就可以了,我们之后再来解释。
(3). 成员函数的分离定义
如果类的成员函数很多很多的话,把这些函数的定义全部写在类的内部就会导致类的定义太长,这种情况下或许我们可以把成员函数定义在类的外面,甚至于说定义在另一个文件当中。
#1.类内定义
类内定义函数的优点是:内部定义的函数会被默认作为内联函数(inline)存在,有可能会类似"宏函数"一样直接用函数内的代码替换,而分离定义的情况下则没有这样的特性
#2.类外定义的基本方式
对于下面这个类:
class Set
{
private:
int* arr;
size_t _size;
size_t _capacity;
public:
static constexpr size_t default_size = 100;
public:
Set();
...
bool empty() const;
};
我们要在类外定义构造函数和empty两个函数,我们得这么写:
Set::Set()
: arr(new int[default_size]{0})
, _size(0)
, _capacity(default_size) {}
bool Set::empty() const
{
return (_size == 0);
}
这还是不太困难的,在类外定义函数只要在函数名前加上类名+域解析操作符以保证函数是这个类的方法即可,而在类外定义就能够有效地减少类内的代码行数,这样对于代码的可读性来说是相当好的,因为类本身就只是一个抽象的概念,作为概念就要简洁明了,而更多而的定义之类的内容就可以在外部进行详细说明。
#3.多文件项目编译
(I).能不能写呢?
既然类的声明和定义可以分开写了,那是不是说我们可以把类的定义和声明写在另一个文件中呢?在C语言中我们好像提过可以把函数的声明写在头文件中,把定义写在.c文件中,其实类也是一样,只是这回需要把定义写在.cpp文件中。
(II).怎么写呢?
一种比较常用的开发习惯是,对于每一个类,创建一对cpp-h文件来编写类的声明和定义,一般把类的声明写在.h文件中,把方法等的定义写在.cpp文件中,之后再在.cpp文件中include对应的.h文件即可。
有一个比较重要的问题,我们在C语言教程中提到,头文件的include是在编译预处理阶段完成的,而且完成的过程相当简单粗暴,即把代码直接粘贴到对应的位置上去,那么如果我们在不同的文件中include了相同的头文件,那就会出现重复声明和重复定义的问题,因此我们需要附加宏以避免重复包含,有下面两种解决方案:
宏定义
// Filename : Student.h
#ifndef _STUDENT_
#define _STUDENT_
class Student
{
...
};
#endif
我们在头文件中先检查是否存在_STUDENT_这个宏,不存在的时候就定义_STUDENT_,然后开始这个类的一些声明,然后在最后要记得写endif结束分支
预处理指令
// Filename : Student.h
#pragma once
class Student
{
...
};
这个看起来简单多了,pragma once是一条预处理指令,加上就可以在预处理时避免发生重复包含。
(III).怎么编译呢?
好了,代码写好了,这回不像是之前那样一个文件了,之前的情况下我们只需要用Code::Blocks或者DevC++打开就可以直接编译运行了,现在文件很多的时候,我们就不能做这件事情了,不过既然都是IDE了,这点事情肯定是能做到的,对于直接使用IDE的,我们需要在IDE中创建项目,然后再将文件加到对应的项目当中,这个比较简单,我们讲点复杂的。
我在C和C++教程的第一篇都有提到你也可以用文本编辑器编写代码,之后通过g++、clang++等编译器自行编译,这次我们就来稍微提一提怎么自行编译。
我们在这里使用g++进行编译,其实clang++的编译命令基本都是一致的,对于单文件的项目,我们可以简单地直接输入:
g++ a.cpp -o a.exe -std=c++11 --save-temps -Wall -O1
这条指令会直接将在a.exe后的各个参数都是可选的,-std=c++11是指定了使用的标准为C++11,你可以改成C++14、C++17或者C++20,–save-temps会把编译过程中产生的临时文件全部保存下来,一般包含这些文件:.ii,.s和.o,其中 .ii是经过编译预处理后得到的文件,.s文件是经过编译器生成的汇编代码,而 .o文件之后得到的目标代码文件,目标代码文件再经过链接器链接就可以得到可执行文件了。
-Wall的作用是标出所有警告信息,有些警告信息在不加这条参数的情况下可能不会出现。-O1则是开启O1优化,Ox优化一共有3级—O1、O2、O3,这个东西你可以自己研究研究。还有一些其他的选项,你都可以在网上查到,这里就不细讲了。
把后面这些东西去掉,我们可以简化成以下代码:
g++ a.cpp -o a.exe
对于单一文件,我们只需要这样就可以得到一个可执行文件了。对于多文件的项目,我们需要把整个编译过程做一个分解,首先在编译的过程中,对于函数的定义要一直到链接的时候才会生效,也就是说,如果我们有这样一个文件:
#include <iostream>
int a();
int main()
{
return 0;
}
我们可以使用g++ … -c将它编译到目标代码文件,结果就是,对于这样一个文件只要不进行链接,它完全没有任何问题,因为编译和链接是分开的过程,在编译过程中只要语法正确,一般就不会报错。
所以,对于一个多文件的项目,我们可以将每一个.cpp文件首先编译成为.o文件,再把这一系列.o文件经过链接步骤得到可执行文件。我们可以用下面这一系列代码完成:
g++ *.cpp -c -std=c++17 -Wall
g++ *.o -o main.exe -std=c++17 -Wall
第一步这里的*.cpp是使用了通配符*指代一切.cpp文件,你当然也可以把所有的.cpp文件的名字写上去。第二步是把所有的.o文件编译称为可执行文件main.exe,在Windows下是.exe,在Linux下可以没有后缀或后缀为.out
这样一来就够了,有一个小问题:头文件呢?我们好像没有用到过啊?其实仔细想想问题就解决了,在我们看不到的地方已经用过头文件了:在编译预处理阶段,头文件会被复制粘贴进入对应的.cpp文件中。
然后你又想,既然一个文件可以一条指令编译出来,那我多个文件是不是也可以呢?答案是:确实可以,但是这么做是有缺陷的。
// Filename : Person.h
#pragma once
class Person
{
public:
void Greeting();
};
// Filename : Person.cpp
#include "Person.h"
#include <iostream>
void Person::Greeting()
{
std::cout << "Hello!" << std::endl;
}
// Filename : main.cpp
#include "Person.h"
#include <iostream>
int main()
{
Person p1;
p1.Greeting();
return 0;
}
接下来我们用这段命令编译:
g++ Person.cpp main.cpp -o main -std=c++17 -Wall
完全正常运行,如果.cpp文件很多,那么我们可以用*.cpp代替这些.cpp文件,但使用这个命令编译的问题不在这里,假设我们的项目大的离谱,可能有几百个动态链接库,几千个cpp文件,几十万上百万行代码,那么每次编译可能就要花掉十来分钟甚至一两个小时,假设我就改一行代码,就要把这几百万行代码全部重新编译一次,那也太夸张了。
因此分离编译就相当重要了,假设某个文件的代码没有变化,我们就不需要把这个文件重新进行一次编译,我们只要对经过了修改的文件进行重新编译即可,毕竟在链接过程之前,只要没有语法错误,都是可以正常编译成目标代码文件的。
所以为了实现这个需求,我们可能需要对于每个文件单独写一条编译命令,这对于我们来说其实是很困难的,因为我们需要对于项目的结构梳理得足够清晰才能很好地完成这个工作,所以后来就有了Makefile和CMake,它们可以帮我们更好地完成编译命令的生成工作,当然,选择一个更加适合工程的IDE会帮助我们更快更好地完成这个过程。
(IV).编译后的函数名称
然后还有这么一个问题,如果你在编译的时候加上–save-temps,保留下来的.ii文件其实和.cpp文件的内容没什么太大的区别,但是假设我们去看看.s文件就会发现一些奇怪的东西,我们先写出这段代码:
int a()
{
return 1;
}
int a(int b)
{
return b;
}
int main()
{
int c = a();
int d = a(c);
return 0;
}
两个a()函数构成了重载函数,我们来看看它的汇编代码:
main:
.LFB2:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
call _Z1av
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
movl %eax, %edi
call _Z1ai
movl %eax, -4(%rbp)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
我在这里只截取了main函数的部分,你现在可能看不懂,没关系,我们关注这样两行:
call _Z1av
call _Z1ai
call命令用于调用某个函数,在这里两次调用a函数,一次调用的是_Z1av,一次是_Z1ai,这俩名字看着好奇怪,但是好像我们能发现点什么东西,如果a是函数名,v就是void,代表无参,i就是int,代表参数为int,是不是这样呢?我们再写两个a函数重载试试看:
int a(int b, int c)
{
return b + c;
}
int a(double b)
{
return b;
}
int a(int b, double c)
{
return b+c;
}
这一次在main函数中看到的调用命令如下:
call _Z1av
call _Z1ai
call _Z1aii
call _Z1ad
call _Z1aid
啊哈,这下我们好像明白这个长得奇奇怪怪的函数名是怎么来的了,函数名往后的几个字母代表参数类型,且是依据参数表顺序依次加上去的,_Z其实和编译器有关,不同编译器得到的结果可能不同,但是实际上如果你的函数更加复杂,得到的名称会更加复杂,例如:
namespace Apple
{
namespace Banana
{
namespace Charlie
{
class Maximum
{
public:
int Func()
{
return 1;
}
};
}
}
}
int main()
{
Apple::Banana::Charlie::Maximum m;
int a = m.Func();
return 0;
}
这回调用变成了这样:
call _ZN5Apple6Banana7Charlie7Maximum4FuncEv
是不是感觉自己隐隐约约能理解命名规则了呢?首先_Z是开头,不用管,之后N代表是命名空间,然后5Apple,代表第一层命名空间Apple的字符数为5,第二层是Banana,长度为6,这么一直下去,直到Func这个函数的名字长度为4,之后的E标志着整个函数来源于嵌套的命名空间,最后v则是void,代表函数Func的参数列表为空,所以我们就搞明白了这个命名规则到底是怎么样的,是不是感觉还挺好玩的。
这样一来_Z1ai我们也可以理解了,_Z是开头,1a就是函数名,字符数为1,i是参数类型。还有就是,其实我们可以不用每次都去看汇编代码来找,我们可以使用objdump这个工具查看:
objdump -t a.o
这就可以看到编译之后得到的函数名称了,把函数编译成这样的名字的目的很简单,因为C++中有重载函数这个特性,如果用函数的名字作为编译的名字,那么在后续就可能会产生好几个名字相同的标签,这样的话调用就会出问题了。
关于namespace嵌套,这里可能会涉及到Koenig查找技术,在这里不会具体介绍,你可以去网上查查其他资料了解一下关于嵌套命名空间中的函数调用规则。
(V).看起来怪怪的extern “C”
学到这里其实已经比较深入了,那么你可能会去读一读别人的代码,你可能会发现,有些代码中会给自己的代码加上这样一段:
extern "C" {
}
extern我们大概知道是什么意思,但这个extern "C"是在干什么?我们写出下面这一段代码;
int a()
{
return 1;
}
int main()
{
int c = a();
return 0;
}
很显然这段代码在C/C++中都是合法的,我们分别用gcc和g++进行一次编译,我们来看看得到的函数a的名称,这是g++的结果:
a的名称变为了_Z1av,还可以,我们知道这个名称是怎么来的。接下来是gcc的结果:
这次的函数名字就是a了,合理,gcc是用C语言方式进行编译,C语言不支持函数重载,所以名字是a也没关系,之后在链接的过程中直接通过a就可以找到函数了。
那么有些代码,实际上应该在C和C++中都能运行,并且这些代码或者说函数并不存在重载,那我们应该能够有一种方法能够在两个语言中编译出相同代码,这就引出了extern "C"了,我们把这段代码改成:
extern "C" {
int a()
{
return 1;
}
}
int main()
{
int c = a();
return 0;
}
b.cpp编译出来的结果中可以看到,a的函数名依旧是a,没有变成_Z1av,extern “C” {}的作用就是要求编译器使用C语言的方式进行编译,得到的代码在C和C++中都可以正常运行了。
(4). 代理构造函数
说完了前面那么长一大段,我们稍微休息休息,讲点轻松的,一个类的构造函数实际上可以不用完全重写,我们可以在列表初始化的位置写另一个构造函数的调用,比如:
class A
{
private:
int* arr;
public:
A() : arr(new int[100]{0}) {}
A(int a[]) : A()
{
for (int i = 0; i < 100; i++) {
arr[i] = a[i];
}
}
};
每个构造函数都要申请一段新内存,实际上,无参构造函数实现了这个过程,我们可以在需要用到申请内存的构造函数中调用A(),这样就会完成内存申请的过程,之后还可以继续完成别的内容。
(5). 静态成员
之前我好像提到过静态成员,静态成员的关键字是static,在类中,我们可以声明某个属性或者方法为static,那么这个方法或者属性就不是属于某个特定的对象了,而是属于整个类的,你当然也可以通过对象来访问,但是更加常见的是通过类名直接进行访问。
我们来看个例子:
class A
{
public:
static constexpr int SIZE = 100;
static void greeting() { std::cout << "Hello!" << std::endl; }
};
我们可以通过以下的方式直接操作这些成员:
int main()
{
std::cout << A::SIZE << std::endl;
A::greeting();
return 0;
}
我们需要通过域解析操作符来访问类A中的成员,很明显,如果它们不是静态成员,我们是不可能通过这样的方式来访问的,因为属于对象在不构造之前是不存在的,它的成员也不存在,根本不能进行访问。
(6). 结构化绑定(C++17)
结构化绑定是C++17中出现的一个新特性,在这里讲听起来好像有点不合时宜,因为其实这个东西跟成员函数的关系不是很大(其实是因为一开始忘了这回事,后来写代码的时候想起来了,才在这里加上来的),但是这真的是个很好用的东西,我们一起来了解一下吧!
在C++的STL中有一个容器叫做map,即容纳键—值对的容器,类似于Python中的字典,那么我们在遍历的时候,假设使用基于范围的for循环,例如:
for (auto& i : m)
{
cout << i.first << " : " << i.second << endl;
}
我们遍历出来的i的类型是一个std::pair<K, V>,K和V分别代表map中键和值的类型,pair中的两个成员会分别作为first和second的两个属性存在,所以我们通过i.first就可以访问键,i.second可以访问值,这样就完成了操作,那么假设我想要在for循环里对这个键值对做一些修改,那我可能得这么做:
for (auto& i : m)
{
auto& k = i.first;
auto& v = i.second;
...
cout << i.first << " : " << i.second << endl;
}
这个k和v不能写在一行,因为键和值的类型可能不一致,这样一来是不能写在同一行的auto关键字后面的,那还有点麻烦,C++17中引入了结构化绑定的新特性,我们以后可以这么写了:
for (auto& [k, v] : m)
{
cout << k << " : " << v << endl;
...
}
在这里我们把i替换成了[k, v],实际上就是准备了两个变量用来存储i中两个位置的元素,其实看着还是相当好理解的,就是我取了std::pair<K, V>对象,然后把k和v分别赋值为first和second,前面加上引用就代表他们是引用了。这样k和v的类型可以不一致,C++编译器会自动完成对应的类型推断。
其实这个和Python的解包操作非常相似,例如:
l = [1, 2, 3]
[a, b, c] = l
print(a, b, c, end = ", ")
结果和结构化绑定是一致的,不过一定要记住,结构化绑定的变量个数一定要和用于解析的对象中的公有属性的个数一致,所以假设我们写auto& [k] : m,C++编译器就会报错了,这还告诉我们一个点,就是结构化绑定不只适用于C++自己实现的类,对于一切具备公有属性的类都可以完成这个操作,例如:
#include <iostream>
using namespace std;
class A
{
public:
int a;
double b;
float c;
long long d;
};
int main()
{
A e = {1, 2.0, 0.3f, 2147483648};
auto& [a, b, c, d] = e;
cout << typeid(a).name() << ", " << typeid(b).name() << ", " << typeid(c).name() << ", " << typeid(d).name() << endl;
return 0;
}
就是这样,假设我们把第四个属性设为私有,即:
#include <iostream>
using namespace std;
class A
{
public:
int a;
double b;
float c;
A(int _a, double _b, float _c, long long _d)
: a(_a), b(_b), c(_c), d(_d) {}
private:
long long d;
};
int main()
{
A e = { 1, 2.0, 0.3f, 2147483648 };
auto& [a, b, c, d] = e;
cout << typeid(a).name() << ", " << typeid(b).name() << ", " << typeid(c).name() << ", " << typeid(d).name() << endl;
return 0;
}
结果就会是这样:
不能完成结构化绑定,因为d没有办法从外部访问到,假设说我们把d去掉,那么程序就完全可以正常运行了吗?
不行哦,因为用来绑定的名称和元素的数量不一致,其实我也不知道,试完之后我感觉是挺无语的,如果我们之后要在自己的类上用到结构化绑定,记得把所有的属性设置为公有属性。
(7). 友元函数和友元类
#1.到底什么是友元?
这是这一篇的最后一节,实际上呢,友元函数不属于类的成员函数,但我还是把它放在这一节了,乐。友元来自于英文单词friend,在现实世界中,人们总是可能会向自己的好朋友诉说自己的一些秘密,比如说W心里特别喜欢F,这个事情他可能会选择告诉自己的朋友。
不过每个人自己的秘密对于朋友也不是全都开放的,比如A也喜欢F,但是他谁都没告诉。在C++的世界,类和类之间的关系不总是这么复杂,一个类如果认为另一个类是自己的朋友,它就会把自己的一切秘密都告诉另一个类,听起来好像、、有点危险是吧?
#2.友元函数和友元类的使用
接下来我们来介绍一下友元函数和友元类的使用,友元解决了我们在某个类中需要访问另一个类的私有成员的问题,虽然我们可以在另一个类中定义特别的getter函数来完成这个操作,但是多一次函数调用总归会带来开销,所以直接访问肯定效果会更好一点。
因此友元函数和友元类的使用背景就是:我们需要在某个类中访问另一个类的私有成员,但不想或不能改变另一个类的其他定义,例如Z写了一个类,他的代码水平很差,但是很凑巧拼拼凑凑就把这个类写好了,只要再多写一个getter函数就会爆炸,那我最好是别改他写的代码了,他的类成员是私有的,我想访问它,我就用友元就好了,例如:
#include <iostream>
using namespace std;
class B
{
public:
int c;
};
class A
{
private:
int a;
public:
A() : a(0) {}
A(int b) : a(b) {}
friend int f(const B& b, const A& a);
};
int f(const B& b, const A& a)
{
return b.c + a.a;
}
int main()
{
A a(10);
B b = {20};
cout << f(b, a) << endl;
return 0;
}
我们运行一下看看结果:
很好啊,我们成功访问到了a的私有属性,完成了加法的过程。所以说,友元函数必须在某个类中声明,我们要使得某个函数能够使用一个类的私有属性,就要在需要访问私有属性的类中声明友元函数,也很简单,作为朋友,我要向别人公开秘密,那肯定是我认为你是我的朋友,我才告诉你,不会说是有一个陌生人来说他是我的朋友,也不管我同不同意就直接把我秘密了解了个透彻。
友元类也是一样,在A中如果声明B是A的友元类,那么在B中就可以访问到A的所有私有属性了,但是一般来说最好不要这么做,因为这很明显地破坏了类的封装性。
#3.友元函数属于谁呢?
如果是你,你觉得友元函数是属于哪个类的呢?假设我们有这样一个函数
friend BigFloat operator*(const BigInt& b, const BigFloat& f);
这是一个运算符重载函数,这个我们会在下一节课中提到,在这里我先提一下,作为一个运算符,有两种重载形式,一个是单一参数列表的,比如:
BigFloat operator*(const BigFloat& f);
这个函数只能声明在另一个类中,因为乘法有两个操作数,比如这个重载函数在BigInt中,我们做a * f的时候(a是BigInt对象,f是BigFloat对象),这个*会被解析为a.operator*(f),从而使用上面的函数完成操作,还有另一种则是:
BigFloat operator*(const BigInt& b, const BigFloat& f);
这就是一个正经的双目运算符了,进行a * f的时候会精确匹配到这个函数并进行乘法操作,它肯定不属于任何一个类,假设它属于BigInt,那结果就是乘法有三个操作数了,那可就不太对了是吧? 那么回到前面所说的这个:
friend BigFloat operator*(const BigInt& b, const BigFloat& f);
你肯定一眼就能发现了,不管这个函数属于哪个类,都会导致乘法出现三个操作数,这是绝对不可能发生的事情,因此:友元函数不属于任何一个类,它只是完成了对于私有属性的访问问题。
那么关于友元,我们就暂时说到这里,下一章的运算符重载中我们还会大量使用到友元函数,到那时你会对friend有更加深刻的理解的。
小结
我是真没想到这一章相对于上一章又过了一个多月才写出来,最近是真的太忙了,忙得我都难以置信,不过近期考试结束,也终于算是有了一点闲时间来完成C++的教程。
截止今天(2023年5月18日),我发现自己C语言教程的指针篇的阅读量突破了2000了,实在是非常感谢大家对我的支持,谢谢大家!近期我也打算将指针篇中省略的函数指针以及复杂类型的解析方法写出来,会在近期发出来,作为C语言教程的补充篇。
那么成员函数这一篇中的内容也是相当多的,需要大家好好去理解并且思考一下,这么长的文章中也难免有疏忽,如果大家发现了什么问题,恳请各位不吝赐教,能够在评论区中指出,这对我将会有非常大的帮助。
下一篇C++的教程中我们会讲到类的设计中的另一个重要问题——运算符重载。