《C++ 沉思录》学习笔记——上篇


因为肺炎的事情,整个假期都一直宅在家里。在为祖国母亲担心、为武汉加油的同时,发现「宅」属性被自己挖掘出来,并没有觉得无聊,相反感受了下风平浪静的闲暇(ps 向奋战在一线的伟大医护人员致敬。

终于把一直在看的《C++沉思录看》完了,有很多细节的地方还是没有看懂的,可是读书不就是这个样子,不可能一次就会,每次看都会有每次看的体会吧。对于觉得很新奇或者印象很深刻的地方做个笔记,方便下次阅读。

因为后面的部分是最近看完的,那就从后往前总结吧。

1. 总结(31-32)

设计任何一门语言——也可以说是任何软件——都是有特定的背景的。C++ 使用者愿意为了语言所以提供的强大的表现力和高效率而放弃对简单性的要求。

1.1 通过复杂性获取简单性(31)

1.1.1 类库和语言语义

所有编程语言都是帮助人们用计算机来解决问题的工具。由于这些问题越来越复杂,以及越来越多的人想要使用计算机来解决不同的问题,所以就不可避免地有了要将这种复杂性转交给语言的压力

初学 C++ 的人很难理解赋值和初始化的区别:

  • 在大多数语言中,如果将一个值赋给一个变量,则无论该变量有没有初始化都没有区别

    int x = 7;

    int x; x = 7

    在 C 没有明显区别。

  • 但是 C++ 中这个区别是很重要的,C++ 允许变量「拥有」一定的资源,如果值改变,就必须放弃这些资源,比如,一个变长字符串的类。

    class String {
      private:
        char * data;
        int len;
        //...
    }
    

    String s = “hello”

    String s; s = “hello”;

    的区别至关重要,在第二个例子中必须在处理新值之前释放旧值占用的内存。

1.1.2 抽象和接口

  • 操作系统上支持的文件操作大致分为打开、关闭、读取、写入、查找等,这些抽象的概念对应的真实操作是什么?是磁盘寻道还是什么?

  • 文件和文件系统的概念你真的知道嘛?

    戳这里 :文件系统

  • 文件系统的接口和实现是分离的,只有分离开来,才能实现不只一种的文件系统,甚至能知道不同类型的文件系统。

1.2 说了 Hello world 后再做什么(32)

  • 任何有用的程序都必须和外部世界交流。
  • 工具是获取结果的手段,如果你只注意手段而忽视了结果,就是在浪费时间。
  • 应该只依赖于完全理解和肯定的东西。
  • 增加知识储备的最有效的方法就是用已知的方法尝试新的问题。(ps 选择一个你还不理解的特性,使用这个特性写一个程序,所见即所得,所用即所学)
  • 做理解的事情,理解要做的事情,逐步加深扩展和理解。

2. 技术(27-30)

2.1 自己跟踪自己的类(27)

C++ 的一个基本思想就是通过类定义可以指明当这个类被对象构造、销毁、复制、赋值时应该发生什么事情。

跟踪类,存在的目的是为了让世界知道它们的存在,常用该类来跟踪函数的进入和退出。

2.1.1 设计一个实用的跟踪类——跟踪函数

打印函数入口和出口 =》 支持重定向输入的打印函数入口和出口 =》支持开关+重定向输入的打印函数的入口和出口

  • 入口和出口:类的构造函数和析构函数
  • 支持重定向:类的成员变量中增加输入描述符字段
  • 支持开关:使用全局变量 debug (ps 不是很好,配置文件会更好一点,就这样)

2.1.2 生成对象的审计跟踪——跟踪对象

类真的按照你的想法创建和销毁了嘛,你以为你以为的就是你以为的……,证据说话,最有效,给段代码自己体会下。

class Obj_trace{
  public:
    Obj_trace():ct(++count){
      cout << "object" << ct << " constructed" <<endl;
    }
    ~Obj_trace(){
      cout << "object" << ct << " destroyed" <<endl;
    }
    Obj_trace(const Obj_trace&):ct(++count){
      cout << "object" << ct << " constructed" <<endl;
    }
    Obj_trace& operator==(const Obj_trace&){
      return *this
    }
  private:
    static int count;
    int ct;
}

int Obj_trace:: count =0;

用法:将上述类作为自定义类的成员字段,即可跟着自定义类的创建和销毁。

将上述类创建和销毁的日志进行分析,即可得到对象是否被正确创建和销毁的结论,事实说话,真香。

2.2 在簇中分配对象(28)

直接由问题导出解决方案的情况时很少见的。然而,通过清楚理解问题来简化解决的方法,这并不少见。我们很容易放弃任何关于设计的思考,而直接跳到实现这一步。

2.2.1 问题

假设存在不同类型的 C++ 对象需要一起释放。你会怎么处理?可以运用 C++ 设计的基本原则,使用类来表示一组不同类型的 C++ 对象。假设此处使用 Cluster 类来表示。

class Cluster{
  //....
}

2.2.2 方案 1—— 侵入

对类型本身的侵入性比较重,假设 T 是一个对象的类型。需要以某种方式放入特定的 Cluster c 中。

T *tp = new© T;

为了使上面的方案有效,类型 T 需要存在一个成员 operate new 将 Cluster 作为参数。该操作需要改变 Cluster,所以 Cluster 这个参数必须按照引用传递。成员 opreate new 的声明如下:

void * T::Operate new(size_t, Cluster &);

注意:

  • 上述的方案限制了 Cluster 类的用途(ps thinking why
  • 需要修改 T 的定义,才能将 T 放入到 Cluster 中,想想有木有更通用的办法?

2.2.3 方案 2——非侵入

知识源于生活,但是高于生活。C++ 继承的概念,很像取自于生活,但是变通性更强。此处通过使用继承和一个中间层。即可完成将任何类的对象放入到一个 Cluster 中。定义一个基类 ClusterItem,从这个基类派生出来的派生类对象都可以分配到簇中。

class ClusterItem {
  public:
    void* operator new(size_t, Cluster &);
    //
}

注:详细的例子见书 p322。

2.3 应用器、操纵器和函数对象(29)

规规矩矩的搬了小板凳又看一次还是没懂……,先记下来吧,后面也许某天会突然顿悟也说不准呢。

已知:cout << value可以使变量 value 的值出现在 cout 文件,则 cout << flush 也可以用来强行输出缓冲区的数据。

  • 操纵器:以某种方式作用于由它的所参数表示的数据。从网上查询,操纵器的本质就是函数,这个貌似好理解点。
  • 应用器:是一个重载操作符,它的操作数是一个可操纵的值和一个将作用于这个值的操纵器。

书上的定义写的实在是拗口且难以理解……,没看错,他就是这么写的,怀疑是翻译有问题

上述的例子中,flush 定义为一个操纵器, << 操作符定义为一个应用器。

2.4 将应用程序从输入输出中分离出来(30)

核心目标:设计一个类来表示任意 I/O 库的接口,从而使应用程序与 I/O 之间的耦合大为下降。

动机:库应该使用哪种 I/O 设备,把库捆绑到某个特定的 I/O 设备会限制库的灵活。

2.4.1 问题

定义如下的变长字符串的类:

class String {
  // 各种定义
}

支持 String fullname = "hello world" cout << fullname << endl; 操作需要在上述类中包含一个输出函数:

ostream& opreate<<(ostream& o, const String& s) {
  // 在输出流中打印字符串 s
  return o;
}

弊端:

  • 不使用 iostream 类而使用另一种 I/O 机制的程序,需要包含两个完整的 I/O 库,并且需要容忍由此带来的空间开销。
  • 没有简单方法使用另一种 I/O 机制打印 String

2.4.1 抽象输出——有瑕疵的解决方案

  • 定义一个表示写任意字符序列到任意目的地的抽象基类 Writer
class Writer {
  public:
    vritual ~Writer();
    vritual void send(const char *, int) = 0;
}
  • 使用继承为需要使用的 I/O 库创建一个特殊的 Writer 类。
class FileWriter: public Writer {
  public:
    FileWriter(FILE* f): fp(f){}
    void send(const char *p, int n) {
      for(int i = 0; i < n; i++) {
        putc(*p++, fp);
      }
    }
  private:
    FILE *fp;
}
  • 定义输出操作
#include<Writer.h>

Writer& operator<<(Writer& w, const String& s) {
  for(int i = 0; i < s.size(); i++) {
    char c = s[i];
    w.send(&c, 1)
  }
  return w
}

弊端:

String hello = "hello\n", goodbye = "goodbye\n"
FileWriter(stdout) << hello << goodbye; 这种写法不对。
原因:FileWriter(stdout) 是一个临时值,在第一个 << 操作完成后,这个临时值就可能被销毁掉……

2.4.2 技巧而无蛮力

  • 应用程序中的类采用使用 send 模板进行所有的输出操作这样一个约定。

    send(dest, ptr, n)

  • 每个 I/O 包都需要一个专门为它写的适当的 send 函数。

  • 应用程序的库除了要知道如何使用 send 函数外,不必知道输出的细节,尽可能的解耦合。

注:具体参考 p346

3. 库(23-26)

C++ 是可扩展的。用户不能改变底层语言本身——不能增加新的操作符或者改变语法,但是可以为这门语言增加新的类型。如果某一组累的设计和实现是希望得到广泛应用,那么我们就称之为库。

3.1 日常使用的库(23)

问题:将两份文本加以合并,两份文本分别是标准库提案描述,以及标准库工作组所维护的开放讨论列表。

解决:

  1. awk perl 和 snobol 这样的模式匹配语言
  2. 用 C++ 编写程序

由于目的是生成 C++ 的标准文档,作者认为 C++ 才名正言顺……,你开心就好

解决过程不详细说明,结论是问题的快速解决依赖于 C++ 库的四种功能:

  • 变长字符串
  • 链表(或者至少是字符串链表)
  • 关联数组
  • 正则表达式

3.2 一个库接口设计实例(24)

数据抽象的目的就是控制复杂度。 如果对某个对象的所有单个操作都将对象置于一种合理的状态,那么对象的状态就会始终保持合理。

3.2.1 复杂问题

先看下面 c 程序存在的问题

#include <stdio.h>
#include <dirent.h>

main(){
    
    
  DIR *dp = opendir(".");
  struct dirent *d;
  while(d = readdir(dp))
    printf("%s\n", d->d_name);
  closedir(dp);
  return 0
}
  • opendir 的时候如果目录不存在,返回值是什么?

    返回空指针

  • 如果传给 readdir 的参数是一个空指针会怎么样?

    readdir 可能做了异常检查,或者直接使用空指针导致 core dump

  • 如果传给 readdir 的参数既不是一个空指针也不是一个由 opendir 函数返回的值又会怎么样?

    要做这种检测需要构建存放有效 DIR 对象的表,每次调用 readdir 是都对该表进行搜索。这种处理方式太过于复杂,同时需要消耗的资源较多,通常情况下不这么处理。

  • 对 readdir 的调用返回指向由库分配的内存块指针。什么时候释放这块内存?

3.2.2 优化接口

在 C++ 中重新设计接口,以便在可能的地方不用考虑上述的问题。解法——将上述例子中的, DIR 指针,换成 Dir 对象,Dir 对象表示对目录的一次从查看。

注:参考 p278

3.2.3 温故知新

  • 如果目录不存在会怎么样?

    1. 即使目录打开失败, 也返回一个对象。但是在读取其中的数据的时候需要抛出异常
    2. 打开目录失败,直接抛出异常。
  • 如果传给 readdir 的参数是一个空指针会怎么样?

    C++ 版本中必须通过某个对象调用 readdir 的方法,而那个对象必须是已经被创建了的。

  • 如果传个 readdir 的参数既不是一个空指针也不是一个由 opendir 函数返回的值会怎么?

    理由同上

  • 对 readdir 的调用返回一个指向库分配的内存的指针,什么时候释放这些内存?

    readdir 的用法改为读取用户提供的对象,而不是返回一个指针。这样内存的分配职责就交给用户。完美

3.3 库设计就是语言设计

C++ 的类机制赋予了库设计者非同寻常的力量,效果上相当于把他们转成了潜在的语言设计者。

先看一个简单的字符串类

class String {
  public:
    String(char *p) {
      sz = strlen(p);
      data = new char[sz+1];
      strcpy(data, p);
    }
    ~String() {delete[] data;}
    operator char*() {return data;}
  private:
    int sz;
    char* data;
}

3.3.1 内存耗尽

问:木有足够的空间包含被分配的 String 会发生什么问题呢?

答:类定义没有考虑这种情况,所以我也不知道,皮一下很开心,正确回答是,构造函数里的 new 表达式失败了,在目前的实现中,通常会有三种结果:

  • 抛出异常
  • 整个程序伴随着一个适当的诊断信息退出
  • new 表达式返回 0

注:标准的 C++ 要求实现抛出异常,但是很多程序的实现会采用「兼容模式」返回 0。

3.3.2 复制

问:将某个长度的 String 赋给一个长度不同的 String 应该怎么做?

答: 让我们先想下可能性:

  • 是一个错误嘛?
  • 改变目标 String 的长度?
  • 按照源 String 和目标 String 中短的那个长度复制?
  • 用源 String 填充目标 String 的长度?

以上四种回答肯定都有其适用的情况,无分对错,场景不同而已。

复制构造函数和赋值操作符之间的主要区别是:赋值操作符复制新值之前必须删除旧值。

3.3.3 隐藏实现

String 类看上去好像是隐藏了其内部实现,但是分析 public 的 operator char*() 的实现可以发现:

  • 用户可以获得一个指针,然后用它修改保存在 data 中的字符。

    定义一个指向 const char * 的类型转换,可以解决第一个问题。

  • 释放 String 时,它所占用的内存资源也被释放。因此,任何指向 String 的指针到时候都会失效……

    使用一个非操作符的函数来替代 operator const char*(),用户往往会忘记释放非显示获得的资源,所以更明智的做法是让用户提供 data 复制进去的空间。

3.3.4 缺省构造函数

原因:如果不知道 String 的初始化值,就无法创建它。即String s 的定义会导致编译错误。

结果:上述问题是没有默认的构造函数导致的,解决方案是为 String 类增加一个默认的构造函数。

3.4 语言的设计就是库的设计

主要讨论的内容是:C++ 中哪些简化库设计的部分,设计一个好的程序库的要求之一就是彻底隔离接口和实现。使用库提供的字符串类,用户不必知道字符串内部的实现细节。

包括:

  • 抽象数据类型——成员函数及其可见度控制。

  • 库和抽象数据类型

    程序库这个概念包涵了类设计者对类内涵的理解。而类设计者与类使用者不同,甲方乙方?

  • 内存分配

  • 按成员赋值和初始化

  • 异常处理

    很多时候,面对异常,我们也不能很好的做出处理。能做的选择似乎是终止程序或者设置某种错误状态,但是实际中这两种处理都不好。类应该足够健壮,最好不要放弃工作和终止程序。降级?

注:这章理解的并不是很好,有空重补下?嗯,先就这个样子吧

4. 碎碎念

虽然还是有很多细节不理解,但是终于写完了上篇的总结,明天应该会是个好天气吧,希望确诊的人数不要增加,希望每个人都:

  • 平安喜乐
  • 万事胜意

Guess you like

Origin blog.csdn.net/phantom_111/article/details/104139733