C++ 的怪物化和升级(IV)--类和对象简介 1
前言
类的引入,是C++面向对象特点的基础。那么类是什么呢?看完本篇相信你就会知道。
引子
C语言中的结构体
C语言是面向过程的结构化和模块化的编程语言,关注的是过程,通过具体的步骤,一步一步解决问题。 C++语言是基于面向对象的,关注的是对象,通过将一件事情拆分成不同的对象,靠对象之间的交互解决问题。 在C语言中,有者和类相似的概念 - 结构体。 我们可以在C语言中创建不同的结构体类型,通常是把一些变量封装在结构体中,抽象为一个新类型。 比如C语言实现栈(部分):
在C语言中结构体中只封装了数据成员(变量),具体的功能实现(函数)在结构体外部。数据成员和函数实现之间是分开的、相互独立的。 这样就会产生一些问题: 实现相同的功能,代码一般较长,即实现比较麻烦; 往往涉及大量的指针操作,这非常容易出现意料之外的错误,使得我们必须非常小心。 结构体没有对使用者做出任何限制,太*了。我们很多时候是不希望直接操作结构体里的数据的,使用者可能会选择不调用对应的功能函数而直接操作结构体里的数据,极有可能使用者并没有注意到实现的细节就直接使用结构体变量中的数据,非常容易导致出错。 C++语言则引入了类的概念,改进C语言存在的问题,并用类实现了面向对象的操作。
C++中的结构体
C++从C而来,可以兼容C语言代码,C语言所写的结构体在C++中也支持,体现了C++语言的向前兼容。
同时,C++对C中的结构体struct
进行了扩展和升级,struct
结构体具有了和C++中类class
基本相同的功能。
//升级的struct,与 类 class的功能相同
//C语言用法 - 结构体
struct ListNode {
int val;
struct ListNode* next;
};
//C++用法 - 类;C语言不能这么用(C的语法不支持)
struct ListNode {
int val;
ListNode* next;
};
于是,数据结构栈的写法可以变成下面的写法
C++中的结构体struct
为了和C语言中的结构体struct
兼容,在没有访问限定符时,默认是成员变量和成员函数公共的。
C++中的类class
则没有这个包袱,在没有访问限定符时类的成员变量和成员函数是私有的。
类的定义
关键字class
,后接一个类名chassName
(标识符),接着是一对花括号{}
括起来的类体,最后以分号;
结束。
类体中的内容称为类的成员:
类体中的变量称为成员变量,也叫作做的属性;
类中的函数称为成员函数,也叫做类的方法。
class className{
//类体:成员函数 + 成员变量
};
类的定义方式
声明和定义全部放在类体中
在类中定义的成员函数编译器默认其为内联函数
class Stack {
public:
void Init(size_t capacity = 4) {
_array = (int*)malloc(sizeof(int) * capacity);
if (_array == nullptr) {
perror("Init file");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
void Destroy() {
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
void Push(int val) {
if (_top == _capacity) {
size_t newcapacity = _capacity * 2;
int* tmp = (int*)realloc(_array, sizeof(int) * newcapacity);
if (tmp == nullptr) {
perror("Push flie");
exit(-1);
}
_array = tmp;
_capacity = newcapacity;
}
_array[_top] = val;
++_top;
}
void Pop() {
--_top;
}
int Top() {
return _array[_top - 1];
}
bool Empty() {
return _top == 0;
}
private:
int* _array;//指向数组
size_t _top;//栈顶的下一个位置
size_t _capacity;//栈容量
};
类的声明和定义都放在类中,这比较好理解,但是有一个问题:类中的成员函数比较少还可以这么整,但当类中成员函数较多时类就显得臃肿不堪了,也不方便去对类进行和调试,不能直观的分析类的功能。
声明和定义分离
类中的成员函数
statement.h
class Stack {
public:
//缺省值能声明或定义一处给出
void Init(size_t capacity = 4);
inline void Destroy();
bool Empty();
private:
int* _array;
size_t _top;
size_t _capacity;
};
class Queue {
public:
void Init();
void Destroy();
bool Empty();
private:
typedef struct ListNode {
int val;
struct ListNode* next;
}LTNode;
LTNode* _head;
LTNode* _tail;
size_t _size;
};
define.cpp
#include "statement.h"
//这里需要 加域作用限定符 指定属于哪个类
void Stack::Init(size_t capacity) {
_array = (int*)malloc(sizeof(int) * capacity);
if (_array == nullptr) {
perror("Init file");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
void Stack::Destroy() {
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
bool Stack::Empty() {
return _top == 0;
}
void Queue::Init() {
_head = _tail = nullptr;
_size = 0;
}
void Queue::Destroy() {
while (!Empty()) {
LTNode* del = _head;
_head = _head->next;
free(del);
}
_head = _tail = nullptr;
_size = 0;
}
bool Queue::Empty() {
return _head == nullptr;
}
类中成员函数声明和定义分离的好处:
类体中的代码大量减少,只保留了成员函数的声明,需要时可以不看成员函数具体实现,通过快速在类中浏览成员函数的声明就可以迅速了解类的大致功能,方便他人也方便自己。
定义与声明分离,也可以保护代码,防止函数实现被修改,避免源码的泄露。
定义静态库或动态库,只提供接口给使用者,从而隐藏具体的实现细节。
** 类外成员函数实现的一个错误:**
原因是编译器不知道函数Init()
到底是属于哪个类的。
没有指定查找的地方时,编译器默认首先在函数内部局部域查找,找不到再去全局域查找,再找不到就报错了。
需要注意的是:
类外的成员函数在具体实现时,在函数开始需要使用对应的类名className
和域作用限定符::
对成员函数进行修饰限定,这是为了说明该成员函数是属于哪个类的,防止编译器发生混淆。
指定查找的地方时,编译器首先去函数内部局部域查找,再去指定的类作用域查找,找不到再去全局域查找,再找不到就报错。
关于C和C++声明和定义的一点说明
C语言要求变量和函数需要先声明或定义再使用,这是因为C语言中各个部分是相对独立的,程序又是顺序执行的,只能向上寻找,C++中的类class
则与之不同。
类中的成员函数和成员变量定义和声明的先后位置是没有要求的,这是因为类是一个作用域,在类内的成员变量和成员函数是一个有机的整体,当需要使用类内的某个变量或函数时,会在类中所有地方寻找,而不是在使用的地方之前寻找。
类中成员变量的命名习惯
先来看一个例子:
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;
};
形参与类内成员函数存在同名的情况,这可能会导致一些问题。
解决方法1:类内成员变量前加上域作用限定符修饰
方法2:类内成员变量定义时,对变量名进行手动修饰,如:加上前缀、后缀、大小写等。目的是区分变量和传入的形参。
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;
//_day 或 day_ 或 m_day 或 mDay等
};
int main() {
Date d;
d.Init(2122, 10, 10);
d.Print();
}
类的访问限定符和封装
访问限定符
分类
访问限定符分为
公有 :
public
私有:private
保护:protected
class A {
public:
int a;
private:
int b;
protected:
int c;
};
int main() {
A A1;
return 0;
}
public
修饰的成员在类外可以直接访问;
protected
和private
修饰的成员在类外不能被直接访问;
访问权限作用域从该访问限定符开始直到下一个访问限定符出现为止;如果后面没有访问限定符,作用域就到
}
结束;也就是说,域作用限定符把类作用域分隔开了,形成一个个属性不同的小作用域
C++中struct和class的主要区别:
class
的默认访问权限为private
,而struct
默认访问权限为public
。struct
可以当作结构体使用,也可以作为类使用。 这是为了兼容C语言中的struct
的使用,C语言中结构体的变量可以直接在结构体外使用,相当于是公共public
的。
访问限定符只有在编译时起作用(所以挑战访问限定符时在编译期间产生的是编译错误,由编译器控制),当数据映射到内存后,没有任何访问限定符上的区别。
封装
首先我们直到面向对象的三大特征:封装、继承和多态。
封装的概念
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口用以和对象进行交互。 封装本质上是一种对数据和方法的管理,使用者因此可以方便的使用类。 C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来 隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。
在类体外定义成员时,需要使用作用域操作符::
指明成员属于哪个类域。
这里有一个问题:
这里可以像命名空间域那样访问命名空间成员那样,使用域作用限定符::
访问某个类域中的某个成员吗?
答案是不能。
命名空间中变量或函数等是已经定义的,有着储存空间,是一个实际对象;而类只是一种类型 - 类类型,不占任何储存空间,不是一个实际的对象,只有在类实例化 - 定义了类对象后,才能访问到类对象内部成员。
类的实例化
类的实例化:用类类型创建对象的过程。
类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没 有分配实际的内存空间来存储它; 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量 ** 类实例化出对象就像现实中使用建筑设计图建造出房子**,类就像是设计图,类只是一个设计,实例化出的对象 才能实际存储数据,占用物理空间
引子:
来看一个简单的日期类:
class A {
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() {
A a;
a.Init(2122, 10, 3);
a.Print();
A b;
b.Init(3122, 10, 3);
b.Print();
}
我们思考一个问题:日期类Date
的成员函数Init()和Print()
都只有一个地址,不同的类对象调用Init()
函数时,成员函数Init()
怎么区分到底是哪一个对象调用的呢?它应该初始化那个对象呢?
C++中引入了this指针
解决了这个问题:C++编译器给每个“非静态的成员函数“增加了一个隐藏
的指针参数this
,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有对成员变量
的操作,都是通过该指针去访问操作的。只不过所有的操作对用户是透明的,即用户不需要来传递,编
译器自动完成。
this指针特性
this指针类型
类类型* const
因此,this指针
本身是不能被修改的,是指针常量,而可以修改this指针指向的对象,这也与成员变量的修改相呼应,即成员变量是通过this指针改变的。
this指针
只能在成员函数的内部使用,这是因为this是以成员函数形参的形式接受实参类对象的地址的,在成员函数栈帧创建时保存在成员函数的栈帧中。
this指针
本质上是成员函数的形参,当对象调用成员函数时,将类对象的地址作为实参传递给this形参,所以对象中不存储this指针 。
class A {
public:
void Init(int year, int month, int day) {
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print() {
cout << "this: " << this << endl;
//cout << _year << "/" << _month << "/" << _day << endl;
cout << this->_year << "/"
<< this->_month << "/" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
A a;
a.Init(2122, 10, 3);
a.Print();
cout << "&a: " << &a << endl;
A b;
b.Init(3122, 10, 3);
b.Print();
cout << "&b: " << &b << endl;
return 0;
}
this指针
是成员函数的第一个隐含的指针形参,一般不需要由用户传递,而是由编译器通过ecx寄存器
自动传递。
类对象模型
类对象储存方式的思考
首先需要明确: 不同的类对象每次调用相同的成员函数时,该成员函数的地址都是相同的,也就是说,程序运行期间,函数代码转换成的二进制指令只有一份,储存在某处供不同类对象调用。 类对象如果保存成员函数,实际上保存的是成员函数的地址。 而不同的类对象的相同的成员变量则是完全不同的,不同对象成员变量最多值是相等的,地址一定是不相同,因为不同的类对象有着有系统分配的独属于自己的储存空间,而对象的成员变量则分别在自己的储存空间中,这与成员函数不同。
-
- 对象中包含类的各个成员
思路1::每一个类对象即保存成员变量,也保存成员函数的地址。 相同的函数地址被多次保存,由此产生的空间浪费不可忽视。 这种思路被舍弃。
-
- 类对象存放成员变量,成员函数共同存放在一个地方,形成函数表,而类对象只保存函数表的地址
思路2:每一个类对象除了都保存必要的成员变量之外,就只保存了类成员函数的函数表的地址,相比思路1,空间已经节约很多了。类函数表把类中的成员函数都放在且在内存中的某块空间而形成的。找到类函数表的地址,就可以找到对应的类成员函数了。
-
- 类对象只存放成员变量,成员函数共同存放在公共代码区
虽然思路2的空间已经节约很多了,但是还是存在着额外的空间占用,即类函数表地址的存放,所以还需要更完美的改进。 **思路3:**类对象中只存放成员变量的大小,类的总大小就是类对象所有成员变量的大小。而类对象的成员函数全部存放到了内存的公共代码区(常量区),这样当类对象调用类成员函数时,编译器直接去公共代码区去寻找待调用的成员函数即可。 在公共代码区存放的成员函数编译器直接就能够找到,不需要类对象自己保存类函数表地址然后自己寻找了。
所以结果显而易见,思路3被保留了下来:类对象中只存放成员变量的大小,类对象的成员函数全部存放到了内存的公共代码区(常量区)。
计算类对象的大小
普通类
一个类对象中可能存放着不同类型变量和许多成员函数。那么类的实例(对象)的大小是多少呢? 其实,类对象的大小的计算和C语言中计算结构体变量的大小是相同的,都需要考虑内存对齐。 在计算类对象大小时,注意到类与C语言中结构体不同的是类域中有成员函数,那么类域中成员函数占不占类对象的大小呢?
答案是不占,类域中成员函数被统一放在了公共代码区(常量区),所以类中只考虑所以成员变量所占空间的大小即可。
空类或空成员函数类
class A {
void func2() {
;
}
};
//空类
class B{
};
//类储存方式的选择:
//类大小的计算:与C语言结构体相同
int main() {
A A1;
//不储存有效数据,占位,标识对象存在
cout << sizeof(A1) << endl;
B B1;
//不储存有效数据,占位,标识对象存在
cout << sizeof(B1) << endl;
return 0;
}
空类和没有成员函数的类的对象大小是
1byte
,而不是0byte
; 这一个字节大小不储存有效数据,而是占位,标识该对象存在,用以区分没有类的情况0byte
。
结构体内存对齐
- 第一个成员在与结构体偏移量为0的地址处;
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处;注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
- 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍;
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍;
this指针
引子:
来看一个简单的日期类:
class A {
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() {
A a;
a.Init(2122, 10, 3);
a.Print();
A b;
b.Init(3122, 10, 3);
b.Print();
}
我们思考一个问题:日期类Date
的成员函数Init()和Print()
都只有一个地址,不同的类对象调用Init()
函数时,成员函数Init()
怎么区分到底是哪一个对象调用的呢?它应该初始化那个对象呢?
C++中引入了this指针
解决了这个问题:C++编译器给每个“非静态的成员函数“增加了一个隐藏
的指针参数this
,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有对成员
变量的操作,都是通过该指针去访问操作的。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
this指针特性
this指针类型
类类型* const
因此,this指针
本身是不能被修改的,而可以修改this指针指向的对象,这也与成员变量的修改相呼应,即成员变量是通过this指针改变的。
this指针
只能在成员函数的内部使用,这是因为this是以成员函数形参的形式接受实参类对象的地址的,在成员函数栈帧创建时保存在成员函数的栈帧中。
this指针
本质上是成员函数的形参,当对象调用成员函数时,将类对象的地址作为实参传递给this形参,所以对象中不存储this指针 。
class A {
public:
void Init(int year, int month, int day) {
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print() {
cout << "this: " << this << endl;
//cout << _year << "/" << _month << "/" << _day << endl;
cout << this->_year << "/"
<< this->_month << "/" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
A a;
a.Init(2122, 10, 3);
a.Print();
cout << "&a: " << &a << endl;
A b;
b.Init(3122, 10, 3);
b.Print();
cout << "&b: " << &b << endl;
return 0;
}
this指针
是成员函数的第一个隐含的指针形参,一般不需要由用户传递,而是由编译器通过ecx寄存器
自动传递。
为什么要设计出this指针?
这还是要从C语言说起: C语言实现数据结构时,比如实现一个栈,首先需要创建一个栈的结构体类型
typedef struct Stack{
//...
}Stack;
在定义栈的功能函数时往往需要传入栈实例的地址
void StackInit(Stack* pst) { };
void StackPush(Stack* pst) { };
既然每个栈的函数都需要入栈实例的地址,前人在设计类时考虑了这一点,C++中在实现类成员函数时就把数据结构实例的地址默认传入了,该地址就被隐藏起来了,对该地址的使用也隐藏起来了。也就是说变成了编译器帮助使用者完成对象(实例)地址的传入,即编译器做的事增多了,使用者要做的事变少了。
C语言和C++实现数据结构比较
C++语言实现数据结构更有优势和方便。
C实现栈
typedef int STDataType;
struct Stack {
STDataType* val;
int top;
int capacity;
};
//初始化
void StackInit(struct Stack* pst) {
assert(pst);
pst->val = NULL;
pst->top = 0;
pst->capacity = 0;
}
//压栈
void StackPush(struct Stack* pst, int val) {
assert(pst);
if (pst->top == pst->capacity) {
int newcapacity = pst->capacity == 0 ? 4 : pst->capacity * 2;
STDataType* tmp = (STDataType*)realloc(pst->val, sizeof(STDataType) * newcapacity);
if (tmp == nullptr) {
perror("realloc file");
exit(-1);
}
pst->val = tmp;
pst->capacity = newcapacity;
}
pst->val[pst->top] = val;
++(pst->top);
}
//出栈
void StackPop(struct Stack* pst) {
assert(pst);
--(pst->top);
}
//取栈顶元素
int StackTop(struct Stack* pst) {
assert(pst);
return pst->val[pst->top - 1];
}
//是否为空
bool StackEmpty(struct Stack* pst) {
assert(pst);
return pst->top == 0;
}
//销毁栈
void StackDestroy(struct Stack* pst) {
assert(pst);
free(pst->val);
pst->top = pst->capacity = 0;
}
对于数据结构栈,在用C语言实现时,Stack相关操作函数有以下共性:
- 每个函数的第一个参数都是
Stack*
; - 函数中必须要对第一个参数检测,因为该参数可能会为
NULL
; - 函数中都是通过
Stack*
参数操作栈的; - 调用时必须传递Stack结构体变量的地址;
- 结构体中只能定义存放数据的结构,操作数据的方法(函数)不能放在结构体中,即数据和操作数据 的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出 错 ;
- 这也说明了this指针不是凭空而来,正是在C语言熟练的基础上,前人才引入了this指针,帮助我们更方便的使用C++来写数据结构的代码。
C++实现栈
class Stack {
public:
//栈初始化
void Init(int capacity = 4) {
_val = (int*)malloc(sizeof(int) * capacity);
if (_val == nullptr) {
perror("Init file");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
//压栈
void Push(int val) {
if (_top == _capacity) {
int newcapacity = _capacity == 0 ? 4 : _capacity * 2;
int* tmp = (int*)realloc(_val, sizeof(int) * newcapacity);
if (tmp == nullptr) {
perror("realloc file");
exit(-1);
}
_val = tmp;
_capacity = newcapacity;
}
_val[_top] = val;
++(_top);
}
//出栈
void Pop() {
--(_top);
}
//取栈顶元素
int Top() {
return _val[_top - 1];
}
//栈是否为空
bool Empty() {
return _top == 0;
}
//销毁栈
void Destroy() {
free(_val);
_val = nullptr;
_top = _capacity = 0;
}
private:
int* _val;
int _top;
int _capacity;
};
C++中通过类可以将数据 以及 操作数据的方法(函数)进行配合,通过访问权限可以控制那些方法在 类外可以被调用,即封装,在使用时就像使用自己的成员一样。 每个方法不需要传递Stack*的参数了,由编译器自动传递给隐式的
this指针
,编译器编译之后该参数会自动还原,即C++中Stack *
参数是编译器维护的,C语言中需用用户自己维护。``
结语
本节最主要介绍类的基本概念,并与结构体进行了比较。 下次再见!
上一篇: 注释|人际关系第 IV 类
推荐阅读
-
C++ 的怪物化和升级(IV)--类和对象简介 1
-
Java 类加载器的作用 - 简介:类加载器是 Java™ 中一个非常重要的概念。类加载器负责将 Java 类的字节码加载到 Java 虚拟机中。本文首先详细介绍了 Java 类加载器的基本概念,包括代理模型、加载类的具体过程和线程上下文类加载器等。然后介绍了如何开发自己的类加载器,最后介绍了类加载器在 Web 容器和 OSGi™ 中的应用。 类加载器是 Java 语言的一项创新,也是 Java 语言广受欢迎的重要原因之一。它允许将 Java 类动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 开始出现,最初是为了满足 Java Applets 的需求而开发的,Java Applets 需要从远程位置下载 Java 类文件并在浏览器中执行。现在,类加载器已广泛应用于网络容器和 OSGi。一般来说,Java 应用程序的开发人员不需要直接与类加载器交互;Java 虚拟机的默认行为足以应对大多数情况。但是,如果遇到需要与类加载器交互的情况,而您又不太了解类加载器的机制,就很容易花费大量时间调试异常,如 ClassNotFoundException 和 NoClassDefFoundError。本文将详细介绍 Java 的类加载器,帮助读者深入理解 Java 语言中的这一重要概念。下面先介绍一些基本概念。 类加载器的基本概念 顾名思义,类加载器用于将 Java 类加载到 Java 虚拟机中。一般来说,Java 虚拟机以如下方式使用 Java 类:Java 源程序(.java 文件)经 Java 编译器编译后转换为 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码并将其转换为 java.lang 实例。每个实例都用来表示一个 Java 类。通过该实例的 newInstance 方法创建该类的对象。实际情况可能更加复杂,例如,Java 字节代码可能是由工具动态生成或通过网络下载的。 基本上,所有类加载器都是 java.lang.ClassLoader 类的实例。下面将详细介绍这个 Java 类。 java.lang.ClassLoader 类简介 java.lang.ClassLoader 类的基本职责是根据给定类的名称为其查找或生成相应的字节码,然后根据这些字节码定义一个 Java 类,即 java.lang.Class 类的实例。除此之外,ClassLoader 还负责加载 Java 应用程序所需的资源,如图像文件和配置文件。不过,本文只讨论它加载类的功能。为了履行加载类的职责,ClassLoader 提供了许多方法,其中比较重要的方法如表 1 所示。下文将详细介绍这些方法。 表 1.与加载类相关的 ClassLoader 方法
-
标题:C++ 类和对象(上)------超详细解析,小白必看系列-C++对struct进行升级,把struct升级成了类,有如下标志: 1️⃣:结构体名称可以做类型 2️⃣:里面可以定义函数 代码演示其变化过程: struct ListNode { int val; //C语言定义next只能这样定义 struct ListNode* next; //C++可以这样 ListNode* next; //C++可以定义函数,C只能定义成员 void Print { //…… } }; int main { //C语言不typedef只能这样定义 struct Student s1; //C++可以这样 Student s2; return 0; } C++里面可以访问其成员也可以访问其函数 struct St
-
F#探险之旅(二):函数式编程(上)-函数式编程范式简介 F#主要支持三种编程范式:函数式编程(Functional Programming,FP)、命令式编程(Imperative Programming)和面向对象(Object-Oriented,OO)的编程。回顾它们的历史,FP是最早的一种范式,第一种FP语言是IPL,产生于1955年,大约在Fortran一年之前。第二种FP语言是Lisp,产生于1958,早于Cobol一年。Fortan和Cobol都是命令式编程语言,它们在科学和商业领域的迅速成功使得命令式编程在30多年的时间里独领风骚。而产生于1970年代的面向对象编程则不断成熟,至今已是最流行的编程范式。有道是“*代有语言出,各领风骚数十年”。 尽管强大的FP语言(SML,Ocaml,Haskell及Clean等)和类FP语言(APL和Lisp是现实世界中最成功的两个)在1950年代就不断发展,FP仍停留在学院派的“象牙塔”里;而命令式编程和面向对象编程则分别凭着在商业领域和企业级应用的需要占据领先。今天,FP的潜力终被认识——它是用来解决更复杂的问题的(当然更简单的问题也不在话下)。 纯粹的FP将程序看作是接受参数并返回值的函数的集合,它不允许有副作用(side effect,即改变了状态),使用递归而不是循环进行迭代。FP中的函数很像数学中的函数,它们都不改变程序的状态。举个简单的例子,一旦将一个值赋给一个标识符,它就不会改变了,函数不改变参数的值,返回值是全新的值。 FP的数学基础使得它很是优雅,FP的程序看起来往往简洁、漂亮。但它无状态和递归的天性使得它在处理很多通用的编程任务时没有其它的编程范式来得方便。但对F#来说这不是问题,它的优势之一就是融合了多种编程范式,允许开发人员按照需要采用最好的范式。 关于FP的更多内容建议阅读一下这篇文章:Why Functional Programming Matters(中文版)。F#中的函数式编程 从现在开始,我将对F#中FP相关的主要语言结构逐一进行介绍。标识符(Identifier) 在F#中,我们通过标识符给值(value)取名字,这样就可以在后面的程序中引用它。通过关键字let定义标识符,如: let x = 42 这看起来像命令式编程语言中的赋值语句,两者有着关键的不同。在纯粹的FP中,一旦值赋给了标识符就不能改变了,这也是把它称为标识符而非变量(variable)的原因。另外,在某些条件下,我们可以重定义标识符;在F#的命令式编程范式下,在某些条件下标识符的值是可以修改的。 标识符也可用于引用函数,在F#中函数本质上也是值。也就是说,F#中没有真正的函数名和参数名的概念,它们都是标识符。定义函数的方式与定义值是类似的,只是会有额外的标识符表示参数: let add x y = x + y 这里共有三个标识符,add表示函数名,x和y表示它的参数。关键字和保留字关键字是指语言中一些标记,它们被编译器保留作特殊之用。在F#中,不能用作标识符或类型的名称(后面会讨论“定义类型”)。它们是: abstract and as asr assert begin class default delegate do donedowncast downto elif else end exception extern false finally forfun function if in inherit inline interface internal land lazy letlor lsr lxor match member mod module mutable namespace new nullof open or override private public rec return sig static structthen to true try type upcast use val void when while with yield 保留字是指当前还不是关键字,但被F#保留做将来之用。可以用它们来定义标识符或类型名称,但编译器会报告一个警告。如果你在意程序与未来版本编译器的兼容性,最好不要使用。它们是: atomic break checked component const constraint constructor continue eager event external fixed functor global include method mixinobject parallel process protected pure sealed trait virtual volatile 文字值(Literals) 文字值表示常数值,在构建计算代码块时很有用,F#提供了丰富的文字值集。与C#类似,这些文字值包括了常见的字符串、字符、布尔值、整型数、浮点数等,在此不再赘述,详细信息请查看F#手册。 与C#一样,F#中的字符串常量表示也有两种方式。一是常规字符串(regular string),其中可包含转义字符;二是逐字字符串(verbatim string),其中的(")被看作是常规的字符,而两个双引号作为双引号的转义表示。下面这个简单的例子演示了常见的文字常量表示: let message = "Hello World"r"n!" // 常规字符串let dir = @"C:"FS"FP" // 逐字字符串let bytes = "bytes"B // byte 数组let xA = 0xFFy // sbyte, 16进制表示let xB = 0o777un // unsigned native-sized integer,8进制表示let print x = printfn "%A" xlet main = print message; print dir; print bytes; print xA; print xB; main Printf函数通过F#的反射机制和.NET的ToString方法来解析“%A”模式,适用于任何类型的值,也可以通过F#中的print_any和print_to_string函数来完成类似的功能。值和函数(Values and Functions) 在F#中函数也是值,F#处理它们的语法也是类似的。 let n = 10let add a b = a + blet addFour = add 4let result = addFour n printfn "result = %i" result 可以看到定义值n和函数add的语法很类似,只不过add还有两个参数。对于add来说a + b的值自动作为其返回值,也就是说在F#中我们不需要显式地为函数定义返回值。对于函数addFour来说,它定义在add的基础上,它只向add传递了一个参数,这样对于不同的参数addFour将返回不同的值。考虑数学中的函数概念,F(x, y) = x + y,G(y) = F(4, y),实际上G(y) = 4 + y,G也是一个函数,它接收一个参数,这个地方是不是很类似?这种只向函数传递部分参数的特性称为函数的柯里化(curried function)。 当然对某些函数来说,传递部分参数是无意义的,此时需要强制提供所有参数,可是将参数括起来,将它们转换为元组(tuple)。下面的例子将不能编译通过: let sub(a, b) = a - blet subFour = sub 4 必须为sub提供两个参数,如sub(4, 5),这样就很像C#中的方法调用了。 对于这两种方式来说,前者具有更高的灵活性,一般可优先考虑。 如果函数的计算过程中需要定义一些中间值,我们应当将这些行进行缩进: let halfWay a b = let dif = b - a let mid = dif / 2 mid + a 需要注意的是,缩进时要用空格而不是Tab,如果你不想每次都按几次空格键,可以在VS中设置,将Tab字符自动转换为空格;虽然缩进的字符数没有限制,但一般建议用4个空格。而且此时一定要用在文件开头添加#light指令。作用域(Scope)作用域是编程语言中的一个重要的概念,它表示在何处可以访问(使用)一个标识符或类型。所有标识符,不管是函数还是值,其作用域都从其声明处开始,结束自其所处的代码块。对于一个处于最顶层的标识符而言,一旦为其赋值,它的值就不能修改或重定义了。标识符在定义之后才能使用,这意味着在定义过程中不能使用自身的值。 let defineMessage = let message = "Help me" print_endline message // error 对于在函数内部定义的标识符,一般而言,它们的作用域会到函数的结束处。 但可使用let关键字重定义它们,有时这会很有用,对于某些函数来说,计算过程涉及多个中间值,因为值是不可修改的,所以我们就需要定义多个标识符,这就要求我们去维护这些标识符的名称,其实是没必要的,这时可以使用重定义标识符。但这并不同于可以修改标识符的值。你甚至可以修改标识符的类型,但F#仍能确保类型安全。所谓类型安全,其基本意义是F#会避免对值的错误操作,比如我们不能像对待字符串那样对待整数。这个跟C#也是类似的。 let changeType = let x = 1 let x = "change me" let x = x + 1 print_string x 在本例的函数中,第一行和第二行都没问题,第三行就有问题了,在重定义x的时候,赋给它的值是x + 1,而x是字符串,与1相加在F#中是非法的。 另外,如果在嵌套函数中重定义标识符就更有趣了。 let printMessages = let message = "fun value" printfn "%s" message; let innerFun = let message = "inner fun value" printfn "%s" message innerFun printfn "%s" message printMessages 打印结果: fun value inner fun valuefun value 最后一次不是inner fun value,因为在innerFun仅仅将值重新绑定而不是赋值,其有效范围仅仅在innerFun内部。递归(Recursion)递归是编程中的一个极为重要的概念,它表示函数通过自身进行定义,亦即在定义处调用自身。在FP中常用于表达命令式编程的循环。很多人认为使用递归表示的算法要比循环更易理解。 使用rec关键字进行递归函数的定义。看下面的计算阶乘的函数: let rec factorial x = match x with | x when x < 0 -> failwith "value must be greater than or equal to 0" | 0 -> 1 | x -> x * factorial(x - 1) 这里使用了模式匹配(F#的一个很棒的特性),其C#版本为: public static long Factorial(int n) { if (n < 0) { throw new ArgumentOutOfRangeException("value must be greater than or equal to 0"); } if (n == 0) { return 1; } return n * Factorial (n - 1); } 递归在解决阶乘、Fibonacci数列这样的问题时尤为适合。但使用的时候要当心,可能会写出不能终止的递归。匿名函数(Anonymous Function) 定义函数的时候F#提供了第二种方式:使用关键字fun。有时我们没必要给函数起名,这种函数就是所谓的匿名函数,有时称为lambda函数,这也是C#3.0的一个新特性。比如有的函数仅仅作为一个参数传给另一个函数,通常就不需要起名。在后面的“列表”一节中你会看到这样的例子。除了fun,我们还可以使用function关键字定义匿名函数,它们的区别在于后者可以使用模式匹配(本文后面将做介绍)特性。看下面的例子: let x = (fun x y -> x + y) 1 2let x1 = (function x -> function y -> x + y) 1 2let x2 = (function (x, y) -> x + y) (1, 2) 我们可优先考虑fun,因为它更为紧凑,在F#类库中你能看到很多这样的例子。 注意:本文中的代码均在F# 1.9.4.17版本下编写,在F# CTP 1.9.6.0版本下可能不能通过编译。 F#系列随笔索引页面