现在的位置: 首页 > 综合 > 正文

论编码中数据(资源)和业务的整合(c++)

2018年05月28日 ⁄ 综合 ⁄ 共 11193字 ⁄ 字号 评论关闭

        之前写过一个项目,用c++写最初是一万行左右,前段时间把他重构了一遍,一下子就变成了四千行多一些的量集。才发现原始项目的设计真的很糟糕,很多重复code和不合理的架构设计使代码很容易膨胀且不易维护。其中问题最多的地方就是资源和业务的管理。

        我之前写的是一个图像测试框架,基本上就是一个用来测试各种图像处理算法的平台。平台用到的资源就是一幅一幅的图像数据了,而业务就是处理这些图像数据的方法。事实上大多数的重构工作就是如何将这两个冤家分分开。

        这里要先声明几个前提,首先是我在开发时非常讲究最小集合这个概念,什么意思?就是如果我有某个功能的需求,那我一定会找专门提供这个功能的工具和库,而不会选择有这个功能的大型工具和库。举个例子,如果我在写代码时需要解析jpeg文件,我会去寻找专门解析jpeg格式的类库,而不会找opencv来干这事。之所以要这样就是为了让代码不含冗余的东西,这样你的代码就能拥有最大的灵活性,方便你后期的修改,整合,移植。如何我要用opencv,那也一定是用他里面的图像处理算法,而不会仅仅只是解析一张jpeg图片。我一向认为刚刚好的代码才是最好的代码。

        现在有一个很简单的场景,就是写个代码来解析jpeg图片,你如何实现?linux下有libjpeg这个库可供你调用,如何用网上也早有教程和代码。那问题就来了,你该如何把对这个库的用法非常透明、优雅地融合到你的项目中?

        先简单说明一下这个库怎么用吧,linux内置,源代码中直接引用jpeglib.h,编译的时候在g++后加上-ljpeg参数即可。基本的读写功能实现如下:

#include <stdio.h>
#include <stdlib.h>
#include <iostream>
#include <jpeglib.h>

void imageProcess(unsigned char* src_image, unsigned char** dest_image, 
                  int width, int height, int dimension) {
  for (int i = 0; i < height; i++) {
    for (int j = 0; j < width * dimension; j++) {
      (*dest_image)[width * dimension * i + j] = src_image[width * dimension * i + j];
    }
  }
}

int main() {
  struct jpeg_decompress_struct cInfoDcp;
  struct jpeg_compress_struct cInfoCp;
  struct jpeg_error_mgr jErrDcp;
  struct jpeg_error_mgr jErrCp;

  cInfoDcp.err = jpeg_std_error(&jErrDcp);
  cInfoCp.err = jpeg_std_error(&jErrCp);
  jpeg_create_decompress(&cInfoDcp);
  jpeg_create_compress(&cInfoCp);

  FILE * infile;
  char iFilename[] = "image.jpg";
  if ((infile = fopen(iFilename, "rb")) == NULL) {
    fprintf(stderr, "can't open %s\n", iFilename);
    exit(1);
  }

  FILE * outfile;
  char oFilename[] = "image_out.jpg";
  if ((outfile = fopen(oFilename, "wb")) == NULL) {
    fprintf(stderr, "can't open %s\n", oFilename);
    exit(1);
  }

  jpeg_stdio_src(&cInfoDcp, infile);
  jpeg_stdio_dest(&cInfoCp, outfile);
  jpeg_read_header(&cInfoDcp, TRUE);
  jpeg_start_decompress(&cInfoDcp);

  cInfoCp.image_width = cInfoDcp.output_width;
  cInfoCp.image_height = cInfoDcp.output_height;
  cInfoCp.input_components = cInfoDcp.output_components;
  cInfoCp.in_color_space = JCS_RGB; /* colorspace of input image */

  jpeg_set_defaults(&cInfoCp);
  jpeg_start_compress(&cInfoCp, TRUE);

  unsigned char* buffer_src = new unsigned char[
                                cInfoDcp.output_height *
                                cInfoDcp.output_width *
                                cInfoDcp.output_components];
  unsigned char* buffer_dest = new unsigned char[
                                 cInfoDcp.output_height *
                                 cInfoDcp.output_width *
                                 cInfoDcp.output_components];

  while (cInfoDcp.output_scanline < cInfoDcp.output_height) {
    unsigned char* src_adress = buffer_src + cInfoDcp.output_components *
                                cInfoDcp.output_width *
                                cInfoDcp.output_scanline;
    jpeg_read_scanlines(&cInfoDcp, &src_adress, 1);
  }

  imageProcess(buffer_src, &buffer_dest, cInfoDcp.output_width,
               cInfoDcp.output_height, cInfoDcp.output_components);

  while (cInfoCp.next_scanline < cInfoCp.image_height) {
    unsigned char* dest_adress = buffer_dest + cInfoCp.input_components *
                                 cInfoCp.image_width * cInfoCp.next_scanline;
    jpeg_write_scanlines(&cInfoCp, &dest_adress, 1);
  }

  delete [] buffer_src;
  delete [] buffer_dest;
  jpeg_finish_decompress(&cInfoDcp);
  jpeg_finish_compress(&cInfoCp);
  fclose(infile);
  fclose(outfile);
}

        以上代码实现了最简单的读写功能,就是从image.jpg读取信息后放到buffer_src内存中,然后将buffer_src内存数据拷贝到buffer_dest内存里,最后把buffer_dest内存保存成image_out.jpg文件。可以看到洋洋洒洒写了近100行code,其实也就是实现了这个基本的不能再基本的功能,如果你不细看根本分不清哪一块是负责读取jpg图片,哪一块是负责把修改后的数据重新保存成图片。这种一眼看不出功能又仅仅只是实现了最基本功能的code可以说是最经典的反面教材。

        罗列一下以上code“烂”的地方:

1. 把业务逻辑写在main函数里。发生这种情况,我只会理解成你只是突然有了个想法想快度测试一下的一个变通手段,如果谁说正式code他也将这么写,那可以肯定他一定没工作过。

2. 读和写的功能杂揉在了一起。这种浆糊式的code,写的时候我也和很多人一样,“很爽”,完全投入进去只关注逻辑是否正确从不管架构合不合理。如果一群人在开发某个项目时也这么干,一个月后这项目就没人能开发下去了,因为谁都不知道哪快code是干嘛了。

       肯定要把功能分开,怎么分?最容易想到的方法就是划分成函数,一个管输入,一个管输出。接下来就是考虑函数的参数了(因为是功能性函数,返回值可以不用考虑一个void搞定),函数名字可以取ReadJpegImage、SaveJpegImage。首先我肯定不会考虑在函数内部new个内存来保存图像,因为那就意味着同时也要在函数内部delete。如果你考虑在外部delete,就会写出一个有隐患的code,因为你随时必须记住有个变量要delete,但往上却找不到new他的地方。当然你也可以用智能指针,只不过在不是很大的项目中没有必要出动智能指针这种设计方案,而且靠智能指针来弥补设计上的缺陷始终是指标不治本的方法。

       那ReadJpegImage的参数基本就可以敲定为filename要读取的文件名,imagedata保存图像数据的数组阵列,因为在未读取图片之前我无法知道图像的长和宽,因此无法一开始就确定所需内存的大小,自然只能用vector类型了(虽然也可以额外写个函数来预先读取图像的长和宽,但为了这个目的而让原本简单的设计复杂化是非常不可取的)。最后确定的参数就是一个输入变量filename,4个输出变量,分别是imagedata图像原始数据,width图像宽度,height图像高度,components颜色通道(rgb)的数目(绝大多数时候是3)。所以函数可以写成:

void ReadJpegImage(const char* filename, std::vector<unsigned char>* imagedata,
                   int* width, int* height, int* components)

       输出函数可以写成:

void SaveJpegImage(const char* filename, const std::vector<unsigned char>& imagedata,
                   int width, int height, int components)

       输出函数的参数列表虽然和输入函数很相似,但输出函数的所有参数都是输入参数,没有输出参数,意思就是函数内部的操作不会影响到入参的原始值。

       经过上述修改后,code看起来就会是这样:

#include <stdio.h>
#include <stdlib.h>
#include <iostream>
#include <vector>
#include <jpeglib.h>

void ReadJpegImage(const char* filename, std::vector<unsigned char>* imagedata,
                   int* width, int* height, int* components) {
  struct jpeg_decompress_struct jpeg_decompress;
  struct jpeg_error_mgr decompress_error;
  jpeg_decompress.err = jpeg_std_error(&decompress_error);
  jpeg_create_decompress(&jpeg_decompress);

  FILE* in_file;
  if ((in_file = fopen(filename, "rb")) == NULL) { 
    fprintf(stderr, "can't open %s\n", filename);
    exit(1);
  }

  jpeg_stdio_src(&jpeg_decompress, in_file);
  jpeg_read_header(&jpeg_decompress, TRUE);
  jpeg_start_decompress(&jpeg_decompress);

  *width = jpeg_decompress.output_width;
  *height = jpeg_decompress.output_height;
  *components = jpeg_decompress.output_components;

  unsigned char* buffer_src = new unsigned char[*height * *width * *components];

  while (jpeg_decompress.output_scanline < *height) {
      unsigned char* src_adress = buffer_src + *components * *width *
                                  jpeg_decompress.output_scanline;
      jpeg_read_scanlines(&jpeg_decompress, &src_adress, 1);
  }

  for (int i = 0; i < *width * *height * *components; ++i) {
      imagedata->push_back(buffer_src[i]);
  }

  delete [] buffer_src;
  jpeg_finish_decompress(&jpeg_decompress);
  fclose(in_file);
}

void SaveJpegImage(const char* filename, const std::vector<unsigned char>& imagedata,
                   int width, int height, int components) {
  struct jpeg_compress_struct jpeg_compress;
  struct jpeg_error_mgr jpeg_error;
  jpeg_compress.err = jpeg_std_error(&jpeg_error);
  jpeg_create_compress(&jpeg_compress);

  FILE * out_file;
  if ((out_file = fopen(filename, "wb")) == NULL) { 
    fprintf(stderr, "can't open %s\n", filename);
    exit(1);
  }
  jpeg_stdio_dest(&jpeg_compress, out_file);

  jpeg_compress.image_width = width;
  jpeg_compress.image_height = height;
  jpeg_compress.input_components = components;

  jpeg_compress.in_color_space = JCS_RGB; 	/* colorspace of input image */
  jpeg_set_defaults(&jpeg_compress);
  jpeg_start_compress(&jpeg_compress, TRUE);
  unsigned char* buffer_dest = new unsigned char[height * width * components];

  for (int i = 0; i < width * height * components; ++i) {
    buffer_dest[i] = imagedata[i];
  }

  while (jpeg_compress.next_scanline < height) {
      unsigned char* dest_adress = buffer_dest  + components *
                                   width * jpeg_compress.next_scanline;
      jpeg_write_scanlines(&jpeg_compress, &dest_adress, 1);
  }
  delete [] buffer_dest;
  jpeg_finish_compress(&jpeg_compress);
  fclose(out_file);

}

int main(int argc, char** argv) {
  std::vector<unsigned char> img_src;
  int width = 0;
  int height = 0;
  int components = 0;

  ReadJpegImage("image.jpg", &img_src, &width, &height, &components);
  std::vector<unsigned char> img_dst(img_src);
  SaveJpegImage("image_out.jpg", img_dst, width, height, components);
}

       嗯,这下看起来干净多了, main函数可以很清晰地看出哪行code干了什么,两个函数的实现也可以放到其他文件中。似乎看起来一切都很美好,只是还有那么几多乌云。

       从ReadJpegImage和SaveJpegImage这两个函数看,他们的参数都非常的相似,或者换个说法,某些变量总会被这几个函数用到,分别是图像的原始数据和图像的长宽。这些数据是什么?不就是一张图本身包含的信息吗?那也就是说,如果还有其他针对这张图进行处理的函数肯定也会用到这些信息的,也就是说这些参数将在各个业务函数之间被反复传递。有经验的程序员到这里早就闻出了不妙的味道,参数出现相同也是一种重复code,消灭重复code无论在哪种编程哲学中都是适用的。

       这种变量在不同函数间被反复传递的情况会导致很多恶果,首先,如果某个被作为参数的变量发生变化或有新的参数添加进来时,这些函数的参数列表肯定每个都需要进行修改。举个例子,如果某个需求要求我不能使用vector来存储图像数据时,我必须对所有参数列表中使用了这个变量的函数都要进行修改,这是一种很费劲的因重复code而导致的修改。其次,如果要添加新的参数,当然也是每个函数都需要添加,这不但导致重复的工作量也导致了参数列表的不断膨胀。所以可以看出来,简单粗暴地采用函数来管理业务后期会因为不断变化的参数列表和函数数目而使维护成本不断上升。

       所以如果有一堆功能相关同时大家又都共用着同样几个变量的时候,就把这些全打包成类吧。函数就成了类的成员函数,而那些共用的变量就成了成员变量。

       根据这个思想我又可以再次将code重构成下面这种(之后只贴头文件的code):

#ifndef  INCLUDE_JPEG_H_
#define  INCLUDE_JPEG_H_

class Jpeg {
 public:
  Jpeg(const char* file_name);
  Jpeg(const Jpeg& jpeg);  // deep copy
  ~Jpeg() {if (m_image_data != NULL) delete m_image_data;}

  const char* GetFileName() const;
  int GetWidth() const;
  int GetHeight() const;
  int GetComponents() const;

  // get raw image data, read only
  const std::vector<unsigned char>* GetRawImageData() const;

  unsigned char GetPixel(int index);
  void SetPixel(int index, unsigned char colour);

  void Save(const char* out_file_name);

 private:
  void Read();

  const char* m_file_name;
  int m_width;
  int m_height;
  int m_components;
  std::vector<unsigned char>* m_image_data;
};

#endif  // INCLUDE_JPEG_H_

        重构后用类进行管理,可以看到成员函数Read()和Save(const char* out_file_name)就是负责之前读写功能的函数,而参数列表则明显减少很多。这样之后再增加新的成员函数就可以十分方便地直接使用成员变量。

        但新的问题又出现了:

1. 又多了不少其他成员函数,Get系和Set系成员函数,提供外界获取和修改类中数据的方法,如果出现新的成员变量,这一类函数还会增加。

2. 成员变量较多,而且很可能还会增长。

        先看第一个问题,这些Get和Set类函数是必不可少的,因为总会有需求外界需要获得你的图像信息,你一个Jpeg类是不可能包办掉所有图像处理功能的。但大量的Get和Set函数和其他业务函数混在一起时,code看起来就挺糟了。当新的需求进来时,业务函数会增加;当新的变量出现时,Get和Set类函数也要增加;这种两头增加会使这个类迅速膨胀,结果就是单个文件上千行甚至上万行都是有可能的。

        然后看第二个问题,成员变量随着新的需求会不断增加,例如要引入图像格式信息就会增加format成员,或者这个类不再只管理一张图,需要管理两张图时又会新增vector变量。

        综上所述,两个问题的唯一解决方式就是重新再构建一个数据类专门保存图像信息数据,彻底将数据和业务分开, 终于这个方案听起来靠谱了。

        这里说个题外话,m_image_data这个变量的类型是指针类型。类似于这种大型数组的保存一定只能在类中以指针或智能指针的方式进行保存。为什么,因为一个类若保存了一系列大型数据,在作复制操作时会严重影响效率并且占用大量内存。即使你的需求要求拷贝时执行深拷贝,也请在拷贝构造函数中使用深拷贝,不要在成员变量直接使用原始对象。在拷贝构造函数中调整拷贝方式非常便捷,你随时可以从浅拷贝修改成深拷贝或者相反,但你想吧某个成员从原始类型转变为指针就会有太多的地方需要跟着修改。因此无论任何情况,都使用指针管理大型数组。

        现在我们有了新的重构方案,再新建一个数据类,问题是怎么建,是在原Jpeg类中新建一个内部类,还是重新创建新文件来构建新类?

        在这个选择上没有经验的工程师将交不少学费,连google都不例外。我曾经工作需要分析过android中PackageManagerService.java这个文件,4.0版之前和之后这个文件发生了相当多的变化,其中一个变化就是大量的内部类被从PackageManagerService.java这个文件中转移了出来,单独创建新文件构建新类。这个改变看似有点形式主义,实则是对架构的一次无奈调整,PMS.java在4.0版本前已经过于臃肿,单个文件就达到了上万行,可以预见如果不作出调整,再迭代几个版本这个文件就会达到3-5万行,再牛的工程师也很难应付这么一个大文件。而被分出去的类,全部无一例外都是数据类。这样一个调整,PMS.java就不再保存数据,就是单独一个只管业务的类了,漂亮的设计。

        到这里,终于开始对业务和数据的管理有了比较清晰的思路了,数据和业务肯定是要分开的,数据肯定是要打包成类的,数据类肯定是要单独成一个文件来管理的。如果某个业务类需要调用数据类中的成员变量,那就通过组合的方式把数据类的指针作为业务类的成员变量即可。

下面是我们构建的新的图像数据类,ImageData类的头文件:

#ifndef INCLUDE_IMAGEDATA_H_
#define INCLUDE_IMAGEDATA_H_

#include <vector>

class ImageData {
 public:
  friend class Jpeg;

  ImageData();
  ImageData(const ImageData& image_data);
  ~ImageData() {
    if (m_data != NULL) {
      delete m_data;
    }
  }

  const char* GetFileName() const;
  int GetWidth() const;
  int GetHeight() const;
  int GetComponents() const;
  unsigned char GetPixel(int index) const;
  void SetPixel(int index, unsigned char colour);

 private:
  const char* m_file_name;
  int m_width;
  int m_height;
  std::vector<unsigned char>* m_data;
};

#endif  // INCLUDE_IMAGEDATA_H_

然后Jpeg这个类更新为:

#ifndef  INCLUDE_JPEG_H_
#define  INCLUDE_JPEG_H_

class ImageData;

class Jpeg {
 public:
  // image_data is not empty
  explicit Jpeg(ImageData* image_data);

  // image_data is empty
  Jpeg(const char* file_name, ImageData* image_data);
  ~Jpeg() {}
  void Save(const char* out_file_name);

 private:
  void Read();
  ImageData* m_image_data;
};

#endif  // INCLUDE_JPEG_H_

可以看出,Jpeg类简化了相当多,里面仅保留了读写业务函数。

        现在总结一下,数据,尤其是未来会发生变化的数据是必须以类的形式进行管理,如果你不确定未来,那干脆直接默认用类管理,不会让你付出多少代价。数据类必须向外暴露出获取和修改原数据的接口,也就是Get和Set系函数。数据类必须单独以一个文件形式存在于项目中。

        也许有人会质疑,为何非要暴露大量Get和Set函数,感觉完全是多此一举。恩,没错,我曾经也是这么认为的。于是,那时的我没有为数据类添加Get和Set函数,而是让想用数据类的业务类或着业务函数直接向我申请friend,这个方法看似很方便,其实隐藏了无数地雷。嗯?没看出来?那我给点提示,如果有5个类向数据类申请friend会怎样?100个类呢?200个类呢?

        不要觉得100、200不太可能,大一点的项目拥有百来个头文件很正常,因此某个数据类被上百个类使用也是有可能的。即使没有上百个,就算50个吧,以每个类平均有5个函数计,code里至少有250个函数可能会修改你数据类中的成员变量,那一旦你发现某个变量不正确时,呵呵,250个地方,你慢慢找吧,而且因为是直接对变量访问,出错位置的变量名可能早就不是原来成员变量名了,你需要debug位置肯定是远远大于250处。这就是滥用friend导致的恶果,friend用多了就是对数据封装的一种破坏。如果把对数据的修改限定在Set类函数中,那很容易通过搜索的方式定位到所有Set函数的调用位置从而定位到错误点。

        什么时候用friend,除非是性能上的需要,如果你有大量的Get和Set类函数的调用,同时又需要高性能,那么有可能成员函数的反复调用所带来的成本也需要考虑,这种时候你可以使用friend,其余情况只能通过Get和Set类函数来访问数据类成员。

        然后数据类还要负责数据的销毁工作,这些全部放在析构函数中完成,而相对应的业务类绝不能越权去销毁数据类,这个很容易理解,业务类只管业务不过问数据的生命周期。

        而对于业务类或者业务函数,他们只会保留一个指向数据类的指针,有且只用这个指针与数据类建立联系,这个指针对于业务类就是成员变量,对于业务函数就是入参。数据类会作为业务类构造函数的输入参数,业务类则通过Get和Set方法来访问修改数据类中的内容。

        以上就是我目前对数据和业务管理的理解,最后再打个比方作总结,数据类就如同流水线上的零件,而业务类就是流水线上操作零件的机器。

        

抱歉!评论已关闭.