从文件对象谈C语言面向对象编程
引言
C语言的输入输出库中,基本都是以FILE结构体为主建立的一套文件操作库。在对其进行简单的认识后,我想探究其背后的编程思路。
首先我们把关注点放在这个贯穿全库的FILE结构体上。FILE结构体和我们平时用法好像不一样,从头至尾我们从来都没有去直接访问过它的结构体成员,一直都是以参数的形式进行调用。这里就不得不提出对象和类这个概念
类与对象
“类” 顾名思义就是种类的意思,种类是根据事物本身的性质或特点而划分的集合。比如:人类,就是符合所有人的共同特征的集合。
“对象” 是类的具体化表现。比如,有外星人来了,他对于抽象的“人类”没法理解,此时想问问有没有具体的能看得见摸得着的东西能解释这个词,这时候你和他说“我就是人类”。此时“我”是“人类”具体化的表现,“我”具有“人类”所有的特征。
那么我们为什么要面向对象的编程呢?是因为这种思维可以将代码高度的封装,可以增加它的复用性。而面向对象的好处在于条理清楚,更加接近于人类解决问题的思维。它们各有各的好处,但是如果是一个复杂的项目,它的过程非常多,我们如果使用面向过程的方式就会非常臃肿,有时候甚至会相互依赖从而增加代码的耦合性1,导致复用性2变差。而面向对象的思想,可以让每个部分都拆分成独立的类,调用的时候是整体调用,代码不仅紧凑,而且耦合性也变低了,同一个类可以被实例化成不同的对象,也增加了代码的复用性。
如何用代码实现面向对象的思想
上述说明了面向对象的思想好处,但是空纸上谈兵是不行的。我们来看看如何用C代码实现上述的内容
虽然C语言是没有class关键词,但是我们有struct——结构体,它可以帮助我们完成封装的任务。接下来我们来看一个例子:
typedef struct
{
int x;
int y;
}pos_t
pos_t point_a = {0,0};
这里我们申明了一个叫pos_t的类用于描述位置。描述位置我就想到了平面直角坐标系,于是它的成员变量就是x, y分别用来描述点的横坐标和纵坐标。当我们需要用到这个类来描述一个点的时候就可以用它来声明一个变量,并给予赋值,这时候就是由类产生了一个对象。类相当于一个模板,而对象是按照这个模板声明的数据。
此时我们需要一个类来描述圆和正方形,于是基于上述的类我们可以创建circle_t和rect_t
typedef struct
{
pos_t center;
unsigned int radius;
}circle_t
typedef struct
{
pos_t left_top;
pos_t right_bottom;
}rect_t
这里就是我们可以看到明显的,pos_t这个结构体被重复利用了,体现了这种方式的复用性。虽然在pos_t的例子里我们直接使用这个类型声明了一个变量,换句话说利用这个类实例化了一个对象,但是这个只是非常非常简单的类,如果这个类很复杂,且含有很多的指针,我们还需要给指针挨个赋值,整体就会变得非常繁杂。于是乎,我们就引入了专门用于创建对象的函数和销毁对象的函数——构造函数和析构函数
函数指针和指针函数
本来这里应该承接上文说构造函数和析构函数,但是出于这篇文章更加容易理解的立场,我还是选择和大家一起复习一下C语言基础。
// 函数指针
int (*func)(int arg); // 形参名可以省略与int(*func)(int);一个意思
// 指针函数
int* func(int arg);
不仅仅是名字很像,它们长得也很像,唯一区别就是多个括号。两句话概括就是:
函数指针: 指向函数的指针。
指针函数: 返回值是指针的函数。
记住后面的是它的本质,其实二者根本就不是一个东西,长得像罢了。那么咱们细细的来说一下二者分别有什么特点。
函数指针,它本质就是个指针。指针咱们知道,要不然指向一块已经存在的内存,要不然就指向一个手动分配的内存。由于需要指向函数,你给它手动分配个内存毫无意义,所以不难理解这个函数指针需要指向一块内存。这时候要说一下函数的首地址就是它名字,那不难理解需要把函数名字赋值给函数指针,函数指针才能正确的被使用起来。为了直观的了解,咱们看一下下面的例子:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int (*pfunc)(int, int); // 参数和返回值必须要和你要赋值的函数一样
pfunc = add;
printf("1+1=%d\n", pfunc(1, 1));
printf("1+1=%d\n", add(1, 1));
return 0;
}
当我刚学函数指针的时候,我给它的评价就是:“脱裤子放屁”。闲的没事干才会赋值后调用函数指针,为啥不直接调用本函数呢?对于函数指针的作用,咱们暂且按下不表,大家只要知道这玩意咋用的就行,后续它会排上大用场。
指针函数 众所周知,我们的c语言函数的局部变量会在出函数的时候被销毁,所以这里不能返回一个局部变量的地址,如果返回参数指针那也没啥意义。所以结果就只能是三种情况:
1. 返回常量的指针。
2. 返回静态变量或者全局变量。
3. 返回手动申请的内存。
第一种方式因为不能修改所以很少用到。第二种方式有个别的地方会用到,比如用于字符串切割的strtok()函数或者是jsmn库就用到这种,本质上靠操作传入的指针。第三种方式,就是今天的主角了,会在过程中产生位于堆的内存,需要手动释放,但是操作不当很容易造成内存泄漏。
实现构造函数和析构函数
现在回过头来看,通过指针函数,我们可以手动分配内存,并且初始化好内存的初始值。在完成了它的使命后,通过析构函数,我们便可以回收内存。接下来通过例子,我们来认识构造函数和析构函数:
#include <stdio.h>
#include <stdlib.h>
typedef struct
{
int x;
int y;
}pos_t;
typedef struct
{
pos_t center;
unsigned int radius;
}circle_t;
typedef struct
{
pos_t left_top;
pos_t right_bottom;
}rect_t;
// 圆的构造函数
circle_t* create_circle(pos_t center, unsigned int radius)
{
circle_t* circle = (circle_t*) malloc(sizeof(circle_t));
circle->center = center;
circle->radius = radius;
return circle;
}
// 圆的析构函数
void delete_circle(circle_t* self)
{
free(self);
}
// 矩形的构造函数
rect_t* create_rectangle(pos_t left_top, pos_t right_bottom)
{
rect_t* rectangle = (rect_t*) malloc(sizeof(rect_t));
rectangle->left_top = left_top;
rectangle->right_bottom = right_bottom;
return rectangle;
}
// 矩形的析构函数
void delete_rectangle(rect_t* self)
{
free(self);
}
int main(void)
{
pos_t center = {0, 0}; // 设定圆心坐标
pos_t left_top= {10, 10}; // 设定矩形左上角坐标
pos_t right_bottom= {20, 20}; // 设定矩形右下角坐标
circle_t* c = create_circle(center, 5); // 创建圆形
delete_circle(c); // 删除圆形
rect_t* r = create_rectangle(left_top, right_bottom); // 创建矩形
delete_rectangle(r); // 删除矩形
return 0;
}
这里我们会了创建对象和删除对象貌似没啥用?别慌接下来就要使用对象。我们可以利用这个对象求它的面积和体积。
double circle_get_perimeter(circle_t* self)
{
return 2*3.14*self->radius;
}
double circle_get_area(circle_t* self)
{
return 3.14*self->radius*self->radius;
}
double rectangle_get_perimeter(rect_t* self)
{
return 2*(self->right_bottom.x-self->left_top.x+self->right_bottom.y-self->left_top.y);
}
double circle_get_area(rect_t* self)
{
return (self->right_bottom.x-self->left_top.x)*(self->right_bottom.y-self->left_top.y);
}
C++中对象的变量叫做属性,对象的函数叫做方法。当然这个只是个称呼,叫什么不是重点。可以发现我们的对象很少去直接访问它的属性,更多的是把它当作第一个参数传入进方法里。
<stdio.h>中的面向对象的思想
在stdio.h中,我们最主要的部分其实是对文件的操作。
利用面向对象的思想,我们来分析一下stdio.h的相关源码
FILE类
先上源码
/* The opaque type of streams. This is the definition used elsewhere. */
typedef struct _IO_FILE FILE;
/* The tag name of this struct is _IO_FILE to preserve historic
C++ mangled names for functions taking FILE* arguments.
That name should not be used in new code. */
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
};
通过寻找我们发现原来它的本质是struct _IO_FILE,是一个自定义的结构体类型,通过注释我们可以略知一二。
FILE对象的构造函数
实际上FILE构造函数是fopen,根据我们上面的思维习惯,构造函数会手动分配内存并且返回初始化好的对象。我们来看一下是不是这样:
extern FILE *fopen(const char *filename, const char *mode)
这里只能看到一个声明。经过查阅资料,发现这个函数是来自于glibc里。于是找到了glibc源码下载了,找到了源码:
#define fopen(fname, mode) _IO_new_fopen (fname, mode)
这里fopen是一个宏,其真身是_IO_new_fopen ,我们继续找下去:
FILE *_IO_new_fopen (const char *filename, const char *mode)
{
return __fopen_internal (filename, mode, 1);
}
继续:
FILE *__fopen_internal (const char *filename, const char *mode, int is32)
{
struct locked_FILE
{
struct _IO_FILE_plus fp;
#ifdef _IO_MTSAFE_IO
_IO_lock_t lock;
#endif
struct _IO_wide_data wd;
} *new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));
if (new_f == NULL)
return NULL;
#ifdef _IO_MTSAFE_IO
new_f->fp.file._lock = &new_f->lock;
#endif
_IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps);
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
_IO_new_file_init_internal (&new_f->fp);
if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL)
return __fopen_maybe_mmap (&new_f->fp.file);
_IO_un_link (&new_f->fp);
free (new_f);
return NULL;
}
首先,验证了我之前说的。确实是手动malloc了内存,并且返回了该内存的指针。这里有个问题它申请的内存是struct locked_FILE返回的却是FILE根据下面的源码:
/* We always allocate an extra word following an _IO_FILE.
This contains a pointer to the function jump table used.
This is for compatibility with C++ streambuf; the word can
be used to smash to a pointer to a virtual function table. */
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
我们能看出来FILE是locked_FILE的第一个成员变量struct _IO_FILE_plus fp中的第一个成员变量。也就是说它申请了一大块内存但是仅仅返回了一部分。那么不是剩余的部分就浪费了吗?带着这个疑问继续往下看
FILE对象的方法
操作文件的函数有很多这里就单拎出来fread()看看内部代码是如何
size_t fread (void *__ptr, size_t __size, size_t __n, FILE *__stream)
仍然是个声明,我们通过glibc找到源码
#define fread(p, m, n, s) _IO_fread (p, m, n, s)
继续找_IO_fread
size_t _IO_fread (void *buf, size_t size, size_t count, FILE *fp)
{
size_t bytes_requested = size * count;
size_t bytes_read;
CHECK_FILE (fp, 0);
if (bytes_requested == 0)
return 0;
_IO_acquire_lock (fp);
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
_IO_release_lock (fp);
return bytes_requested == bytes_read ? count : bytes_read / size;
}
前面的要不是赋值要不就是断言,重要的在_IO_sgetn这个函数内,我们继续查看
size_t _IO_sgetn (FILE *fp, void *data, size_t n)
{
/* FIXME handle putback buffer here! */
return _IO_XSGETN (fp, data, n);
}
这里返回一个宏
#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)
这里其它都是上面传下来的变量,多了一个__xsgetn
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
};
#define JUMP_FIELD(TYPE, NAME) TYPE NAME
/* The 'xsputn' hook writes upto N characters from buffer DATA.
Returns EOF or the number of character actually written.
It matches the streambuf::xsputn virtual function. */
typedef size_t (*_IO_xsputn_t) (FILE *FP, const void *DATA, size_t N);
这三个连起来看就知道了原来__xsgetn是结构体struct _IO_jump_t的一个函数指针
那么这个函数指针的原型应该在构造函数里被初始化了,通过__fopen_internal 函数我们找到了这一行
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
#define _IO_file_jumps jumps
/* Our custom vtable. */
static const struct _IO_jump_t jumps =
{
JUMP_INIT_DUMMY,
JUMP_INIT (finish, method_finish),
JUMP_INIT (overflow, method_overflow),
JUMP_INIT (underflow, method_underflow),
JUMP_INIT (uflow, method_uflow),
JUMP_INIT (pbackfail, method_pbackfail),
JUMP_INIT (xsputn, method_xsputn),
JUMP_INIT (xsgetn, method_xsgetn),
JUMP_INIT (seekoff, method_seekoff),
JUMP_INIT (seekpos, method_seekpos),
JUMP_INIT (setbuf, method_setbuf),
JUMP_INIT (sync, method_sync),
JUMP_INIT (doallocate, method_doallocate),
JUMP_INIT (read, method_read),
JUMP_INIT (write, method_write),
JUMP_INIT (seek, method_seek),
JUMP_INIT (close, method_close),
JUMP_INIT (stat, method_stat),
JUMP_INIT (showmanyc, method_showmanyc),
JUMP_INIT (imbue, method_imbue)
};
#define JUMP_INIT(NAME, VALUE) VALUE
果不其然,在构造函数里我们初始化了这些函数指针的指向函数,所以才能够去使用它。但是问题来了,我们返回的是FILE对象,而后面访问的却是与FILE对象并列的_IO_jump_t 对象。
/* We always allocate an extra word following an _IO_FILE.
This contains a pointer to the function jump table used.
This is for compatibility with C++ streambuf; the word can
be used to smash to a pointer to a virtual function table. */
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
这里如何突破限制调用呢?我们继续看JUMP2 宏是如何实现的
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
#define _IO_JUMPS_FILE_plus(THIS) _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)
这里的_IO_CAST_FIELD_ACCESS 宏是通过结构体成员去推断结构体的头指针的位置。具体的实现方式我就不分析了,有兴趣的可以自己看一下。那么通过这样的“扩容”我们就从FILE升级到了_IO_FILE_plus从而找到了_IO_jump_t 结构体。这里_IO_jump_t 是实现了c++中的虚表。
FILE对象的析构函数
最后就是不得不提的析构函数,析构函数的主要作用是分解对象。那么就一定有free去分解掉malloc出来的东西。我们通过找fclose去看一下
extern int fclose (FILE *__stream);
#define fclose(fp) _IO_new_fclose (fp)
int _IO_new_fclose (FILE *fp)
{
int status;
CHECK_FILE(fp, EOF);
#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
/* We desperately try to help programs which are using streams in a
strange way and mix old and new functions. Detect old streams
here. */
if (_IO_vtable_offset (fp) != 0)
return _IO_old_fclose (fp);
#endif
/* First unlink the stream. */
if (fp->_flags & _IO_IS_FILEBUF)
_IO_un_link ((struct _IO_FILE_plus *) fp);
_IO_acquire_lock (fp);
if (fp->_flags & _IO_IS_FILEBUF)
status = _IO_file_close_it (fp);
else
status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
_IO_release_lock (fp);
_IO_FINISH (fp);
if (fp->_mode > 0)
{
/* This stream has a wide orientation. This means we have to free
the conversion functions. */
struct _IO_codecvt *cc = fp->_codecvt;
__libc_lock_lock (__gconv_lock);
__gconv_release_step (cc->__cd_in.step);
__gconv_release_step (cc->__cd_out.step);
__libc_lock_unlock (__gconv_lock);
}
else
{
if (_IO_have_backup (fp))
_IO_free_backup_area (fp);
}
_IO_deallocate_file (fp);
return status;
}
/* Deallocate a stream if it is heap-allocated. Preallocated
stdin/stdout/stderr streams are not deallocated. */
static inline void _IO_deallocate_file (FILE *fp)
{
/* The current stream variables. */
if (fp == (FILE *) &_IO_2_1_stdin_ || fp == (FILE *) &_IO_2_1_stdout_
|| fp == (FILE *) &_IO_2_1_stderr_)
return;
#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
if (_IO_legacy_file (fp))
return;
#endif
free (fp);
}
可以看到注释写的很清楚,stdin/stdout/stderr 流不会被释放。堆分配的会被释放。
应用
有人会说,这个东西会用到吗?我们不妨举个例子。当我们写spi的lcd驱动的时候,我们会先初始化spi然后写出spi的读写,然后通过规格书去写lcd的寄存器初始化数据,最后发送点的数据给lcd。这种思路就是面向过程的,想要去移植就需要先读懂原代码,然后再次依据此思想去写一遍,利用率非常低。如果我们换一种思路,把这整体分为两个对象,一个是通讯层,一个是驱动层。然后通讯层实现读写的方法,驱动层使用回调函数对接通讯层的读写去实现lcd初始化和lcd刷屏。这样我们如果芯片换了就重写通讯层,如果lcd换了就重写驱动层。可以实现代码的高内聚低耦合性,增加代码复用率。
总结
通过以上我们发现,虽然c是面向过程的语言,但是在一些大型的项目中经常会用到面向对象的思想。可能以前没有遇到过这种思路,但是想要去构建大型工程就必须去理解这种思路,尝试去写,尝试去理解。