高级程序设计 课程的重点知识

15.继承的基本概念和单继承

继承的基本概念

  • 继承机制: 对一个面向对象的程序,在定义一个新的类时,先把已有程序中的一个或多个类的功能全部包含进来,然后在新的类中再给出新功能的定义或对已有类的某些功能重新定义

    新类 <== 已有类的功能 + 新功能/已有功能重定义

单继承

继承中的访问控制

在派生类中访问基类成员

  • C++中, 派生类不能直接访问基类的私有成员
  • 继承与封装的矛盾: 派生类可能访问private, 但不允许c’c
  • 实际上,有了继承机制以后,一个类的成员有两种被外界使用的场合:
    • 通过类的对象(实例)使用
    • 在派生类中使用

      protected访问控制

  • 用protected说明的成员不能通过对象使用,但可以在派生类中使用。
  • C++类向外界提供两种接口:
    • public:对象的使用者(类的实例用户)
    • public+protected:派生类

      派生类成员标识符的作用域

  • 派生类对基类成员的访问除了受到基类的访问控制的限制以外,还要受到标识符作用域的限制。
    • 在类中定义的标识符局部于类定义
    • 在类中任何地方都可以访问
    • 在类外使用类中定义的标识符时,需要通过对象名受限或者类名受限
    • 在类中使用与成员标识符同名的全局标识符时,需要在全局标识符前面加上全局域解析符“::”
  • 作用域嵌套: 对基类而言,派生类成员标识符的作用域是嵌套在基类作用域中的
    • 派生类可使用基类的标识符
    • 基类不能用基类的标识符
  • 同名成员: 如果派生类中定义了与基类同名的成员,则基类的成员名在派生类的作用域内不直接可见(被隐藏,Hidden)。
    • 访问基类同名成员时要用基类名受限
    • 即使派生类中定义了与基类同名但参数不同的成员函数,基类的同名函数在派生类的作用域中也是不直接可见的,可以用基类名受限方式来使用之
    • 也可以在派生类中使用using声明把基类中某个的函数名对派生类开放

      继承方式

      |继承方式\基类成员|public|protected|private|
      |-|-|-|-|
      |public|public|protected|不能访问|
      |protected|protected|protected|不能访问|
      |private|private|private|不能访问|

原private都不可访问, 其余收紧(选继承方式和基类成员中, 修饰符严格的那个)

16.派生类的初始化和消亡处理

派生类对象的初始化

  • 共同完成
    • 基类继承的成员由基类的构造函数初始化
    • 派生类的数据成员由派生类的构造函数初始化
      • 派生类构造函数体前, 将调用基类构造函数
  • 创建派生类的对象

    • 默认情况下,调用基类的默认构造函数
    • 如果要调用基类的非默认构造函数,则必须在派生类构造函数的成员初始化表中指出
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      class A
      { int x;
      public:
      A() { x = 0; }
      A(int i) { x = i; }
      };
      class B: public A
      { int y;
      public:
      B() { y = 0; }
      B(int i) { y = i; }
      B(int i, int j):A(i) { y = j; }
      };
  • 析构派生类的对象

    • 先执行派生类的析构, 再执行基类的析构
  • 类D同时有基类B和成员类M时:
    • 构造: D -> M -> A
    • 析构: A -> M -> D

      派生类拷贝构造函数

  • 派生类的隐式拷贝构造函数: 调用基类的拷贝构造函数
  • 派生类的自定义拷贝构造函数, 自动调用基类的默认构造函数, 可在初始化表中指定调用哪个构造函数

    派生类对象的赋值操作

  • 派生类隐式的赋值操作除了对派生类成员进行赋值外,还将调用基类的赋值操作对基类成员进行赋值
  • 派生类自定义的赋值操作符重载函数不会自动调用基类的赋值操作,需要在自定义的赋值操作符重载函数中显式地指出

    转移构造函数

  • 在创建一个对象用另一个同类型的对象对其初始化时,将会调用拷贝构造函数
  • 当用一个临时或即将消亡的对象去初始化另一个同类的对象时,目前的拷贝构造函数的实现效率有时是不高的
  • 转移构造函数: A(A&& x) 右值引用类型: 不能作为赋值操作符的左操作符
  • 当用一个临时对象或即将消亡的对象去初始化另一个对象时
    • 如果对象类中有转移构造函数,则会去调用转移构造函数来对对象初始化。
    • 否则去调用拷贝构造函数进行对象初始化。(注意:系统不会提供隐式转移构造函数!)

      赋值操作符的重载

  • 用于赋值的对象是一个临时或即将消亡的对象时,目前的赋值操作符重载函数的实现效率有时是不高的
  • 转移赋值操作符: A& operator=(A&& x)
  • 类似转移构造函数, 并且也需要检测自身赋值

17.消息的多态与动态绑定(虚函数)

多态

  • 某个论域中的一个元素存在多种解释
    • 体现
      • 函数名重载
      • 操作符重载
    • 类属性(泛型)
  • 子类型
    • 用类型S替换所有程序P中的类型T, 程序P功能不变, 则S是T的子类型
    • C++中, 以public方式继承的派生类, 可以看作是基类的子类型
  • 面向对象程序设计的多态性
    • 对于public继承关系的2个类, c++中存在下面的多态
      • 一个对象, 多种类型: 派生类的对象的类型, 既可以是派生类, 也可以是基类
      • 一个对象标识, 多种对象: 基类对象的指针, 可以指向派生类对象
      • 消息多态: 可以发送到基类的消息, 也可以发送到派生类

        消息的动态绑定

    • 虚函数: virtual关键字
    • 根据运行时, 实际指向的对象来决定调用哪一个消息
  • 派生类中重定义基类的成员函数
    • 对于基类中的虚函数, 在派生类中定义的, 与之具有相同型构的函数成员, 是对基类该成员的重定义
    • 型构: 名字, 参数个数和类型, 返回值类型要么相同, 要么是派生类
  • 动态绑定的条件
    • 基类的指针或引用
    • 访问的是虚函数
    • 基类构造函数和析构函数中, 对虚函数的调用, 不进行动态绑定
  • 几点说明
    • 只有类的成员函数才能是虚函数, 静态成员函数不能是虚函数
    • 构造函数可以是虚函数, 析构函数可以并且往往是虚函数
    • 只要在基类中说明了虚函数, 派生类的派生类…当中的同型构函数, 也是虚函数, 可不写virtual
  • 运行时刻类型信息: RTTI
    • 使用dynamic_cast<B *>, 进行类型转换, 之后检查转换是否成功

      虚函数与动态绑定的实现(不考)

  • 基类对象和派生类对象成员的空间分配
    • 内存布局: 基类成员低地址, 派生类新成员高地址
    • 内存布局决定, 基类指针可以指向派生类对象
  • 多态的实现
    • 虚函数表: A, B两类, 分别生成虚函数表
    • 虚函数表指针: 类的对象的指针位置(空间头部), 有指向虚函数表的指针, 据此进行动态绑定
    • 虚函数表项类型: typedef void (*FuncPtr)(void *)
    • 虚函数表类型: typedef FuncPtr *VtblPtr
  • 纯虚函数: 没有给出实现的虚函数
    • 函数体: vitrual int f()=0;
    • 派生类中, 所有同型构的函数, 都是虚函数
  • 抽象类: 包含纯虚函数的类
    • 抽象类的作用, 是为派生类提供一个基本框架和公共的对外接口

18.抽象类(纯虚函数)

纯虚函数

  • 纯虚函数是没给出实现的虚函数,函数体用“=0”表示

    抽象类

  • 包含纯虚函数的类称为抽象类
  • 抽象类不能创建对象

19.多继承

  • 多继承是指派生类可以有一个以上的直接基类
    • 继承方式及访问控制的规定同单继承
    • 派生类拥有所有基类的所有成员
    • 基类的声明次序决定
      • 对基类构造函数/析构函数的调用次序
      • 对基类数据成员的存储安排
  • 可以把以public继承方式定义的多继承派生类的对象的地址赋给任何一个基类的指针
    • 这时将会自动进行地址调整: 不同基类指针指向的时候, 指针的值不一样, 指向对应基类数据成员的基地址
  • 名冲突
    • 使用基类名受限: C c; c.A::f(); c.B::f();
  • 重复继承问题(虚基类)
    • 例如: B->A, C->A, D->(B,C), D从A继承了2次
    • 如果要求D中仅有一个A, 则可以在B, C中, 将A继承为虚基类: class B: virtual public A {};
  • 虚基类
    • 间接包含虚基类的类
      • 虚基类的构造函数, 由该类的构造函数直接调用(上例为D); 即由最新构造的类调用
  • 虚基类的实现
    • 一般基类: 内存空间直接连接
    • 虚基类: 虚基类成员移到直接基类最后,原位头留指针指向最后; 简洁派生类同理, 多个指针指向同一个地方

      20.继承和代码复用

  • C++中, 把类看作类型,把以public方式继承的派生类看作是基类的子类型
  • 具有继承关系的两个类之间通常属于一般与特殊的关系(is-a-kind-of)
  • 聚集
    • 代码复用的另一种方式是通过聚集(aggregation,也称聚合)来实现,即把一个类作为另一个类(新类)的成员对象类
    • 在基于对象的程序设计中,一般采用聚集来实现代码复用的
  • 继承与封装存在矛盾,聚集则否。
    • 在继承方式的代码复用中,一个类通过protected访问控制,向外界提供两种接口:
      • public:对象(实例)用户
      • public+protected:派生类用户
    • 在聚集方式的代码复用中,一个类对外只需一个接口:public。
  • 具有聚集关系的两个类不具有子类型关系

    21.泛型(类属)程序设计 模板

    概念

  • 泛型: 一个程序实体能对多种类型的数据进行操作或描述的特性称为类属或泛型(Generics)。
  • 具有类属特性的程序实体通常有:
    • 类属函数:一个能对不同类型的数据完成相同操作的函数。
    • 类属类:一个成员类型可变、但功能不变的类。
  • 基于具有类属性的程序实体进行程序设计的技术称为:c泛型程序设计(或类属程序设计,Generic Programming)。

    类属函数

  • 一个函数能对不同类型的参数完成相同的操作
  • 实现机制

    • 通用指针的参数(C做法): 用通用指针实现类属函数面临的问题

      • 排序函数为例

        1
        2
        3
        4
        void sort(void *base, //需排序的数据(数组)内存首地址
        unsigned int count, //数据元素的个数
        unsigned int element_size, //一个数据元素所占内存大小(字节数)
        int (*cmp)(const void *, const void *) ) //比较两个元素的函数
      • 比较麻烦,需要大量的指针操作。

      • 容易出错!编译程序无法进行类型检查。
    • 函数模板

      函数模板

  • 函数模板是指带有类型参数的函数定义
  • 一般格式

    1
    2
    template <class T> 
    void sort(T elements[], unsigned int count)
    • T1、T2等是函数模板的参数, 函数的<返回值类型> 、<参数表>中的参数类型以及函数体中的局部变量的类型可以是:T1、T2等
  • 实例化

    • 实际上,函数模板定义了一系列重载的函数。
      • 要使用函数模板所定义的函数,首先必须要对函数模板进行实例化:
        • 给模板参数提供一个具体的类型,从而生成具体的函数。
      • 函数模板的实例化通常是隐式的
        • 编译程序根据函数调用的实参类型自动地把函数模板实例化为具体的函数。
      • 这种确定函数模板实例的过程叫做模板实参推导(template argument deduction)。
    • 显式地实例化

      1
      2
      3
      4
      template <class T> 
      T max(T a, T b) { return a > b ? a : b; }

      max<double>(x,m);
      • 也可以进行显式类型转换以交由编译程序推导
  • 带非类型参数的函数模板

    1
    2
    3
    4
    5
    6
    7
    template <class T, int size> //size为一个int型的普通参数
    void f(T a)
    { T temp[size];
    ......
    }

    f<int,10>(1);
    • 这样的函数模板在使用时需要显式实例化

类模板

  • 如果一个类的成员类型可变、但功能不变,则该类称为类属类
  • 类模板的格式为:

    1
    2
    3
    4
    template <class T1,class T2,...> 
    class <类名>
    { <类成员说明>
    }
    • 其中,T1、T2等为类模板的类型参数,在类成员的说明中可以用T1、T2等来作为它们的类型。
  • 实例化

    • 类模板的实例化需要在程序中显式地指出

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      Stack<int> st1; //实例化int型栈类并创建一个相应类的对象
      int x;
      st1.push(10); st1.pop(x);

      Stack<double> st2; //实例化double型栈类并创建一个相应类的对象
      double y;
      st2.push(1.2); st2.pop(y);

      Stack<A> st3; //实例化A类型栈类并创建一个相应类的对象
      A a,b;
      st3.push(a); st3.pop(b); //A中可能要重载赋值操作符!为什么?
    • 不同类模板实例之间不共享类模板中的静态成员

  • 带非类型参数的类模板: 与函数类似

    模板的复用

  • 用模板实现的类属也属于一种多态,称为参数化多态
  • 模板属于源代码复用
  • 模板的定义和实现都放在头文件中, 把模板的源代码全包含进来,以备实例化所需
  • 重复实例的处理
    • 由开发环境来解决:编译第二个模块的时候不生成这个实例。(代价大!)
    • 由连接程序来解决:舍弃一个相同的实例。
  • 类模板的友元函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    template <class T> class A;
    template <class T> void f3(A<T>& a) { ...... }
    template <class T>
    class A
    { T x,y;
    ......
    friend void f1(A<T>& a); //f1不是模板!
    template <class T> friend void f2(A<T>& a); //f2的实例与A的实例多对多友元
    friend void f3<T>(A<T>& a); //f3的实例与A的实例一对一友元
    };
    void f1(A<int>& a) { ...... } //是A<int>的友元,但不是A<double>的友元!
    template <class T> void f2(A<T>& a) {......}
    ......
    A<int> a1; //实例化A<int>
    A<double> a2; //实例化A<double>
    f1(a1); //OK,调用f1(A<int>&)
    f1(a2); //链接错误! 调用f1(A<double>&),但它不存在!
    f2(a1); //实例化f2<int>,它是A<int>和A<double>的友元
    f2(a2); //实例化f2<double>,它是A<int>和A<double>的友元
    f3(a1); //实例化f3<int>,它是A<int>的友元,但不是A<double>的友元!
    f3(a2); //实例化f3<double>,它是A<double>的友元,但不是A<int>的友元!

22.基于STL的编程

STL

  • C++提供了一个基于模板实现的标准模板库(Standard Template Library,简称STL)
    • STL基于模板实现了一些面向序列数据的表示及常用的操作。
    • STL支持了一种抽象的编程模式: 隐藏了一些低级的程序元素,如数组、链表、循环等
  • 容器类模板
    • 容器用于存储序列化的数据,如:向量、队列、栈、集合等。
  • 算法函数模板
    • 算法用于对容器中的数据元素进行一些常用操作,如:排序、统计等。
  • 迭代器类模板
    • 迭代器实现了抽象的指针功能,它们指向容器中的数据元素,用于对容器中的数据元素进行遍历和访问。
    • 迭代器是容器和算法之间的桥梁:传给算法的不是容器,而是指向容器中元素的迭代器,算法通过迭代器实现对容器中数据元素的访问。这样使得算法与容器保持独立,从而提高算法的通用性。

      容器

  • 容器用于表示由同类型元素构成的、长度可变的元素序列。
  • 容器是由类模板来实现的,模板的参数是容器中元素的类型。
  • 主要容器
    • vector<类型>: 随机访问, 尾部增减, 动态数组实现
    • list<类型>: 任意位置增减, 双向链表实现
    • deque<类型>: 随机访问, 头尾增减, 分段的连续空间实现
    • stack<类型>: 尾部增减, 可基于前三者实现
    • queue<类型>: 尾增头减, 基于deque或list
    • priority_queue<类型>: 与queue的操作类似, 带排序功能, 基于deque或vector
    • map/multimap<关键字类型, 值类型>
      • 每个元素是一个pair, first是关键字, second是值, 元素根据关键字排序
      • map: 关键字各不相同; multimap: 关键字可以相同
      • 常常用某种二叉树实现(例如红黑树)
    • set/multiset<类型>: map/multimap的特例, 有关键字没值
    • basic_string<字符类型>: 类似vector, 但提供字符串相关的操作
  • 基本操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    void push_front(const T& x);
    void pop_front();
    // * 分别在容器的头部增加和删除一个元素。适用于list和deque。
    void push_back(const T& x);
    void pop_back();
    // * 分别在容器的尾部增加和删除一个元素。适用于vector、list和deque。
    void push(const T& x);
    // * 在容器的尾部增加一个元素。适用于stack、queue和priority_queue。
    void pop();
    // * 在容器的尾部(适用于stack)或头部(适用于queue和priority_queue)删除一个元素。
    T& front();和const T& front() const;
    // * 获取容器中第一个元素。适用于vector、list、deque和queue。
    T& back();和const T& back() const;
    // * 获取容器中最后一个元素。适用于vector、list、deque和queue。
    T& top();和const T& top() const;
    // * 获取容器尾部元素(适用于stack)或头部元素(适用于priority_queue)。
    T& at(size_type pos);
    // * 获取容器中某位置pos(序号)上的元素(会进行越界检查)。适用于vector、deque和basic_string。
    T& operator[](size_type pos);
    // * 获取容器中某位置pos(序号)上的元素。适用于vector、deque和basic_string。
    ValueType& operator[](const KeyType& key);
    // * 获取容器中某个关键字所关联的值。适用于map。
    iterator begin();
    const_iterator begin() const;
    // * 获取指向容器中第一个元素位置的迭代器。适用于除queue、stack和priority_queue以外的所有容器。
    iterator end();
    const_iterator end() const;
    // * 获取指向容器中最后一个元素的下一个位置的迭代器。适用于除queue、stack和priority_queue以外的所有容器。
    iterator insert(iterator pos, const T& x);
    void insert(iterator pos, InputIt first, InputIt last);
    // * 分别在容器中的指定位置pos(迭代器)之前插入一个和多个元素,返回插入的(第一个)元素的迭代器。适用于vector、list和deque。
    iterator erase(iterator pos);
    iterator erase(iterator first, iterator last);
    // * 分别在容器中删除指定位置pos(迭代器)上的一个和某范围内的多个元素,返回删除后下一个元素的迭代器。适用于vector、list、deque、map/multimap、set/multiset以及basic_string。
    iterator find(const T& key);
    // * 根据关键字在容器中查找某个元素,返回指向元素的迭代器(找到)或最后一个元素的下一个位置(未找到)。适用于map/multimap和set/multiset。
    size_type size() const;
    // * 获取容器中元素个数。适用于所有容器。
    size_type max_size() const;
    // * 获取容器中所允许的元素最大个数。适用于除stack、queue和priority_queue以外的所有容器。
  • 注意:如果容器的元素类型是一个类,则针对该类可能需要:

    • 自定义拷贝构造函数和赋值操作符重载函数
      • 对容器进行的一些操作中可能会创建新的元素(对象的拷贝构造)或进行元素间的赋值(对象赋值)。
    • 重载小于操作符(<)
      • 对容器进行的一些操作中可能要进行元素比较运算。

        迭代器

  • 迭代器(iterator)实现了抽象的指针(智能指针)功能,它们用于指向容器中的元素,对容器中的元素进行访问和遍历
  • 迭代器(类模板实现)的类型
    • 输出迭代器(output iterator,记为:OutIt)
      • 可以修改它所指向的容器元素
      • 间接访问操作(*)
      • ++操作
    • 输入迭代器(input iterator,记为:InIt)
      • 只能读取它所指向的容器元素
      • 间接访问操作(*)和元素成员间接访问(->)
      • ++、==、!=操作。
    • 前向迭代器(forward iterator,记为:FwdIt)
      • 可以读取/修改它所指向的容器元素
      • 元素间接访问操作(*)和元素成员间接访问操作(->)
      • ++、==、!=操作
    • 双向迭代器(bidirectional iterator,记为:BidIt)
      • 可以读取/修改它所指向的容器元素
      • 元素间接访问操作(*)和元素成员间接访问操作(->)
      • ++、–、==、!=操作
    • 随机访问迭代器(random-access iterator,记为:RanIt)
      • 可以读取/修改它所指向的容器元素
      • 元素间接访问操作(*)、元素成员间接访问操作(->)和下标访问元素操作([])
      • ++、–、+、-、+=、-=、==、!=、<、>、<=、>=操作
  • 容器的迭代器
    • vector, deque, basic_string: RanIt
    • list, map/multimap, set/multiset: BidIt
    • queue, stack, priority_queue: 不支持迭代器
  • 迭代器相容
    • InIt/OutIt <- FwdIt <- BidIt <- RanIt

      算法

  • 在STL中,除了用容器类模板自身提供的成员函数来操作容器元素外,还提供了一系列通用的对容器中元素进行操作的函数模板,称为算法(algorithm)
  • 大部分STL算法都是遍历指定容器中某范围内的元素,对满足条件的元素执行某项操作
  • 算法的内部实现往往隐含着循环操作,但这对使用者是隐藏的。使用者只需要提供:容器(迭代器)操作条件以及可能的自定义操作,而算法的控制逻辑则由算法内部实现,这体现了一种抽象的编程模式。
  • 算法与容器之间的关系
    • 不是把容器传给算法,而是把容器的某些迭代器传给它们
    • 这样做的好处是:使得算法不依赖于具体的容器,提高了算法的通用性
  • 一个算法能接收的迭代器的类型是通过算法模板参数的名字来体现的。例如

    1
    2
    template <class InIt, class OutIt>
    OutIt copy(InIt src_first, InIt src_last, OutIt dst_first) { ...... }
  • 算法的操作范围

    • 用算法对容器中的元素进行操作时,大都需要用两个迭代器来指出要操作的元素的范围

      1
      void sort(RanIt first, RanIt last);
      • first是第一个元素的位置
      • last是最后一个元素的[下一个位置]
    • 有些算法可以有多个操作范围,这时,除第一个范围外,其它范围可以不指定最后一个元素位置,它由第一个范围中元素的个数决定
    • 一个操作范围的两个迭代器必须属于同一个容器,而不同操作范围的迭代器可以属于不同的容器
  • 算法的自定义操作条件

    • 有些算法可以让使用者提供一个函数或函数对象来作为自定义操作条件(或称为谓词),其参数类型为相应容器的元素类型,返回值类型为bool
    • 自定义操作条件可分为:

      • Pred:一元“谓词”,需要一个元素作为参数

        1
        2
        3
        4
        5
        size_t count_if(InIt first, InIt last, Pred cond);
        bool f(int x) { return x > 0; }
        vector<int> v;
        ...... //往容器中放了元素
        cout<<count_if(v.begin(),v.end(),f); //统计v中正数的个数
      • BinPred:二元“谓词”,需要两个元素作为参数

        1
        2
        3
        4
        5
        6
        7
        void sort(RanIt first, RanIt last); //按“<”排序
        void sort(RanIt first, RanIt last, BinPred comp); //按comp返回true规定的次序
        bool greater(int x1, int x2) { return x1>x2; }
        vector<int> v;
        ...... //往容器中放了元素
        sort(v.begin(),v.end()); //从小到大排序
        sort(v.begin(),v.end(),greater); //从大到小排序
  • 算法的自定义操作

    • 有些算法可以让使用者提供一个函数或函数对象作为自定义操作,其参数和返回值类型由相应的算法决定。
    • 自定义操作可分为:

      • Op或Fun:一元操作,需要一个参数

        1
        2
        3
        4
        5
        Fun for_each(InIt first, InIt last, Fun f); 
        void f(int x) { cout << ' ' << x; }
        vector<int> v;
        ...... //往容器中放了元素
        for_each(v.begin(),v.end(),f); //对v中的每个元素去调用函数f进行操作
      • BinOp或BinFun:二元操作,需要两个参数

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        T accumulate(InIt first, InIt last, T val); //按“+”操作
        T accumulate(InIt first, InIt last, T val, BinOp op); //由op决定累积的含义
        int f1(int partial, int x) { return partial*x; }
        int f2(int partial, int x) { return partial+x*x; }
        double f3(double partial, int x) { return partial+1.0/x; }
        vector<int> v;
        ...... //往容器中放了元素
        accumulate(v.begin(),v.end(),0); //所有元素和
        accumulate(v.begin(),v.end(),1,f1); //所有元素的乘积
        accumulate(v.begin(),v.end(),0,f2); //所有元素平方和
        accumulate(v.begin(),v.end(),0.0,f3); //所有元素倒数和
      1
      2
      3
      4
      5
      6
      7
      8
      OutIt transform(InIt src_first, InIt src_last,  OutIt dst_first, Op f);
      OutIt transform(InIt1 src_first1, InIt1 src_last1, InIt2 src_first2, OutIt dst_first, BinOp f);
      int f1(int x) { return x*x; }
      int f2(int x1, int x2) { return x1+x2; }
      vector<int> v1,v2,v3,v4;
      ...... //往v1和v2容器中放了元素
      transform(v1.begin(),v1.end(),v3.begin(),f1); //v3中的元素是v1相应元素的平方
      transform(v1.begin(),v1.end(),v2.begin(),v4.begin(),f2); //v4中的元素是v1和v2相应元素的和

23.输入输出(面向对象)

输入/输出(I/O)概述

  • 在C++中,输入/输出是一种基于字节流的操作:
    • 输入的数据看成逐个字节地从外设流入到计算机内部(内存)
    • 输出的数据看成逐个字节地从内存流出到外设
  • 在C++的标准类库提供了用于C++基本数据类型数据的输入/输出操作:
    • 在这些操作的内部实现了基本数据类型与字节流之间的转换
  • 另外,还可以对“<<”和“>>”进行重载,以实现对自定义类型的数据(对象)进行输入/输出操作。
  • C++输入输出的实现途径
    • 过程式——通过从C语言保留下来的函数库中的输入/输出函数来实现。
      • 不是强类型,不利于类型检查,会导致类型相关的运行错误
    • 面向对象——通过C++的I/O类库中的类/对象来实现。
      • 不需要额外指定数据的类型和个数,由数据本身决定,避免了类型与个数相关的错误!
  • I/O分类
    • 面向控制台的I/O
      • 从标准输入设备(如:键盘)获得数据
      • 把程序结果从标准输出设备(如:显示器)输出
    • 面向文件的I/O
      • 从外存文件获得数据
      • 把程序结果保存到外存文件中
    • 面向字符串变量的I/O
      • 从程序中的字符串变量中获得数据
      • 把程序结果保存到字符串变量中
  • I/O类
    • ios
      • istream: ifstream, istrstream
      • ostream: ofstream, ostrstream
      • iostream: fstream, strstream

面向控制台的I/O

  • 预定义的io对象
    • cin(istream类的对象)对应着计算机系统的标准输入设备。(通常为键盘)
    • cout(ostream类的对象)对应着计算机系统的用于输出程序正常运行结果的标准输出设备。(通常为显示器)
    • cerr和clog(ostream类的对象)对应着计算机系统的用于输出程序错误信息的设备(不带和带缓冲),通常情况下它们都对应着显示器。(不受输出重定向的影响)

      控制台输出

  • 输出格式控制
    • 操纵符
      • dec, oct, hex: 进制
      • endl: 换行+flush
      • flush: 缓存中内容立刻输出
      • setprecision(int n):
        • 当输出格式为ios::scientific或ios::fixed时,操纵符setprecision用于设置浮点数小数点后面的位数;
        • 当输出格式为自动方式(既不为ios::scientific也不为ios::fixed,或两者都有)时,操纵符setprecision用于设置浮点数有效数字的个数,这时的输出格式根据有效数字自动确定
      • setiosflags(long flags)/resetiosflags(long flags) :
    • 基于字节流的操作
      • cout.put(char)
      • cout.write(const char *, int)

        控制台输入

  • 空白符(空格、\t、\n)分开输入的各个数据
  • 操纵符
    • cin >> setw(10) >> str; //把输入的字符串和一个’\0’放入str中,最多输入9个字符。
  • 基于字节流的操作
    • get(char &ch);
    • getline(char *p, int count, char delim=’\n’); 输入count个或遇到delim
    • read(char *p,int count);
  • 输入/输出操作符“>>”和“<<”的重载

    • 一般情况

      1
      2
      3
      4
      5
      6
      7
      class A {	
      int x,y;
      public:
      friend ostream& operator << (ostream& out, const A &a);
      };
      ostream& operator << (ostream& out, const A &a)
      { out << a.x << ',' << a.y; return out; }
    • 派生类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      class A {		
      int x,y;
      public:
      ......
      virtual void display(ostream& out) const
      { out << x << ',' << y ; }
      };
      class B: public A {
      double z;
      public:
      ......
      void display(ostream& out) const
      { A::display(out); out << ',' << z ; }
      }
      ostream& operator << (ostream& out, const A& a) {
      a.display(out); //动态绑定到A类或B类对象的display。
      return out;
      }
      A a1; B b1;
      cout << a1 << endl << b1 << endl;

面向文件的I/O

  • 文件基本概念
    • 在C++中,把文件看成是由一系列字节所构成的字节串,对文件中数据的操作(输入/输出)通常是逐个字节顺序进行,因此称为流式文件。
    • 每个打开的文件都有一个隐藏的位置指针,它指出文件的当前读写位置。
    • 进行读/写操作时,每读入/写出一个字节,文件位置指针会自动往后移动一个字节的位置。
  • 文件数据的存储方式
    • 文本方式(text): 只包含可显示的字符和有限的几个控制字符(如:‘\r’、‘\n’、‘\t’等)的编码。
      • 一般用于存储具有“行”结构的文本数据。(可用记事本等软件打开察看)
    • 二进制方式(binary): 包含任意的没有显式含义的纯二进制字节。
      • 一般用于存储无显式结构的数据。
  • 对文件数据进行读写的过程:
    • 打开文件:把程序内部的一个表示文件的变量/对象与外部的一个具体文件关联起来,并创建内存缓冲区。
    • 文件读/写:存取文件中的内容。
    • 关闭文件:把暂存在内存缓冲区中的内容写入到文件中,并归还打开文件时申请的内存资源。

      文件输出

  • 首先创建一个ofstream类的对象,并建立与外部文件之间的联系(打开文件)。
    • 直接方式:ofstream out_file(<文件名>,<打开方式>);
      例如:ofstream out_file(“d:\\myfile.txt”,ios::out);
    • 间接方式: ofstream out_file; out_file.open(<文件名>,<打开方式>);
      例如:out_file.open(“d:\\myfile.txt”,ios::out);
  • 打开方式
    • 打开方式
      • ios::out 默认打开方式
        • 打开一个外部文件用于写操作。
        • 如果外部文件已存在,则首先把它的内容清除;否则,先创建该外部文件。
      • ios::app
        • 打开一个外部文件用于添加(文件位置指针在末尾)操作。
        • 如果外部文件不存在,则先创建该外部文件。
      • ios::out或ios::app分别与ios::binary的按位或(|)
        • 表示按二进制方式打开文件。(默认的是文本方式)
        • 对以文本方式打开的文件,当输出的字符为’\n’时,在某些平台上(如:DOS和Windows平台)将自动转换成’\r’和’\n’两个字符写入外部文件。
    • 判断打开操作是否成功
      • !out_file.is_open() 或 out_file.fail() 或 !out_file
  • 输出数据: <<或成员函数, 与控制台类似
  • 关闭文件
    • 文件输出操作结束时,要使用ofstream的成员函数close关闭文件:out_file.close();
    • 关闭文件的目的:
      • 把文件内存缓冲区的内容写到磁盘文件中!
    • 程序正常结束时,系统也会自动关闭打开的文件。
      • 程序中为什么要显式关闭文件?

文件输入

  • 首先创建一个ifstream类(istream类的派生类)的对象,并与外部文件建立联系。
    • 例如:ifstream in_file(<文件名>,<打开方式>);
    • 或: fstream in_file; in_file.open(<文件名>,<打开方式>);
  • 打开方式
    • ios::in,打开一个外部文件用于读操作。
      • 也可以把ios::in与ios::binary通过按位或操作(|)实现二进制打开方式。默认为文本方式。
      • 对以文本方式打开的文件,当文件中的字符为连续的’\r’和’\n’时,在某些平台上(如:DOS和Windows平台)将自动转换成一个字符’\n’输入。
    • 打开文件时要判断打开是否成功。
      • 判断方式与文件输出的判断一样。
  • 判断成功与否: 读取数据过程中有时需要判断是否正确读入了数据(尤其是在文件末尾处)。
    • 判断是否正确读入了数据,可以调用ios类的成员函数fail来实现:
    • bool ios::fail() const;
    • 该函数返回true表示文件操作失败;返回false表示操作成功。
  • 文件输入操作结束时,要使用ifstream的一个成员函数close关闭文件:in_file.close();
  • 有关文件读写的几点注意:
    • 输入输出格式统一
      • 以文本方式输出的文件要以文本方式输入;以二进制方式输出的文件要以二进制方式输入!
      • 以文本方式读写的文件要以文本方式打开;以二进制方式读写的文件要以二进制方式打开!
    • 以二进制方式存取文件不利于程序的兼容性和可移植性。例如,
      • 在不同计算机平台上,整型数的各字节在内存中的存储次序可能不一样。
      • 在不同的编译环境下,同样的结构类型数据的尺寸(字节数)可能不一样。
  • 文件输入/输出
    • 如果需要打开一个既能读入数据、也能输出数据的文件,则需要创建一个fstream类的对象。
    • 在创建fstream类的对象并建立与外部文件的联系时,文件打开方式应为下面之一:
      • ios::in|ios::out(可在文件任意位置写)
      • ios::in|ios::app(只能在文件末尾写)
  • 文件随机存取
    • 为了能够随机读写文件中的数据,可以显示地指出读写的位置。
    • 对于以输入方式打开的文件,可用下面的操作来指定文件内部指针位置:
      • istream& istream::seekg(<位置>);//指定绝对位置
      • istream& istream::seekg(<偏移量>,<参照位置>); //指定相对位置
      • streampos istream::tellg(); //获得指针位置
    • 对于输出文件,可用下面的操作来指定文件内部指针位置:
      • ostream& ostream::seekp(<位置>);//指定绝对位置
      • ostream& ostream::seekp(<偏移量>,<参照位置>); //指定相对位置
      • streampos ostream::tellp(); //获得指针位置
    • <参照位置>可以是:ios::beg(文件头),ios::cur(当前位置)和ios::end(文件尾)。

24.异常处理

异常概述

  • 程序的错误通常包括:
    • 语法错误:指程序的书写不符合语言的语法规则。
      例如:
      使用了未定义或未声明的标识符
      左右括号不匹配
      ……
      这类错误可由编译程序发现。
    • 逻辑错误(或语义错误):指程序设计不当造成程序没有完成预期的功能。
      例如:
      把两个数相加写成了相乘
      排序功能未能正确排序
      ……
      这类错误可通过对程序进行静态分析和动态测试发现。
    • 运行异常:指程序设计对程序运行环境考虑不周而造成的程序运行错误。
      例如:
      对于“x/y”操作,给y输入了“零”。
      由内存空间不足导致的内存访问错误:
      int p=new int; //动态分配空间,可能失败! p = 10; //如果上面new操作失败,p可能为空指针!
      输入数据的数量超过存放它们的数组的大小,导致数组下标越界。
      多任务环境可能导致的文件操作错误。
  • 在程序运行环境正常的情况下,运行异常的错误是不会出现的。
  • 导致程序运行异常的情况是可以预料的,但它是无法避免的。
  • 为了保证程序的鲁棒性(Robustness),必须在程序中对可能出现的异常进行预见性处理
  • 异常处理的策略
    • 就地处理: 在发现异常错误的地方处理异常
    • 异地处理: 在其它地方(非异常发现地)处理异常

      异常的就地处理

  • 常用做法是调用C++标准库中的函数exit或abort终止程序执行(在cstdlib或stdlib.h中声明)
    • abort立即终止程序的执行,不作任何的善后处理工作。
    • exit在终止程序的运行前,会做关闭被程序打开的文件、调用全局对象和static存储类的局部对象的析构函数(注意:不要在这些对象类的析构函数中调用exit)等工作。

      异常的异地处理

  • 发现异常时,在发现地(如在被调用的函数中)有时不知道如何处理这个异常,或者不能很好地处理这个异常,要由程序的其它地方(如函数的调用者)来处理
  • 一种途径
    • 通过函数的返回值,或指针/引用类型的参数,或全局变量把异常情况通知函数的调用者,由调用者处理异常
    • 通过函数的返回值返回异常情况会导致正常返回值和异常返回值交织在一起,有时无法区分。
      • 通过指针/引用类型的参数返回异常情况,需要引入额外的参数,给函数的使用带来负担。
      • 通过全局变量返回异常情况会导致使用者忽视这个全局变量的问题。(不知道它的存在)
      • 程序的可读性差!程序的正常处理与异常处理混杂在一起。

        C++异常处理机制

  • 把有可能遭遇异常的一系列操作(语句或函数调用)构成一个try语句块。
  • 如果try语句块中的某个操作在执行中发现了异常,则通过执行一个throw语句抛掷(产生)一个异常对象,之后的操作不再进行。
  • 抛掷的异常对象将由程序中能够处理这个异常的地方通过catch语句块来捕获并处理之。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    void f(char *filename)
    { ifstream file(filename);
    if (file.fail())
    throw filename; //产生异常对象,报告错误情况
    int x;
    cin >> x;
    ......
    return 0;
    }
    int main()
    { char str[100];
    ......
    try { f(str); }
    catch (char *fn) //捕获异常
    { ...... //处理异常
    }
    ...... //正常情况
    }
  • try语句

    • try语句块的作用是启动异常处理机制, 上述的<语句序列>中可以有函数调用。格式为:
      1
      2
      3
      try 
      { <语句序列>
      }
  • throw语句

    • throw语句用于在发现异常情况时抛掷(产生)异常对象; 执行throw语句后,接在其后的语句将不再继续执行,而是转向异常处理(由某个catch语句给出)
      1
      2
      throw <表达式>;
      <表达式>为任意类型的C++表达式(void除外)。
  • catch语句

    • catch语句块用于捕获throw抛掷的异常对象并处理相应的异常, catch语句块要紧接在某个try语句的后面

      1
      2
      3
      4
      5
      catch (<类型> [<变量>])
      { <语句序列>
      }
      <类型>用于指出捕获何种异常对象,它与throw所产生的异常对象的类型匹配规则与函数重载的绑定规则类似;
      <变量>用于存储异常对象,它可以缺省,缺省时表明catch语句块只关心异常对象的类型,而不考虑具体的异常对象。
    • 一个try语句块的后面可以跟多个catch语句块,用于捕获不同类型的异常对象并进行处理。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      int g()
      { ......
      try
      { f();
      }
      catch (int) //处理函数f中的throw 1;
      { <语句序列1>
      }
      catch (double) //处理函数f中的throw 1.0
      { <语句序列2>
      }
      catch (char *) //处理函数f中的throw "abcd"
      { <语句序列3>
      }
      <非catch语句>
      }
  • 如果在try语句块的<语句序列>执行中没有抛掷(throw)异常对象,则其后的catch语句不执行,而是继续执行try语句块之后的非catch语句。

  • 如果在try语句块的<语句序列>执行中抛掷了(throw)异常对象,
    • 如果该try语句块之后有能够捕获该异常对象的catch语句,则执行这个catch语句中的<语句序列>,然后继续执行这个catch语句之后的非catch语句。
    • 如果该try语句块之后没有能够捕获该异常对象的catch语句,则按嵌套的异常处理规则进行处理。

      异常处理的嵌套

  • try语句是可以嵌套的:
    • 在try语句块的语句序列执行过程中还可以包含try语句块。
  • 当在内层的try语句的执行中产生了异常,则首先在内层try语句块之后的catch语句序列中查找与之匹配的处理,如果内层不存在能捕获相应异常的catch,则逐步向外层进行查找。
  • 如果抛掷的异常对象在程序的函数调用链上没有给出捕获,则调用系统的terminate函数进行标准的异常处理。
    • 默认情况下,terminate函数将会去调用abort函数。
  • 实现机制示意
    • 每个函数都有一个catch表。
    • 每进入一个try,都会把其后的所有catch入口地址记录在相应函数的catch表中。
    • 执行throw时,
      • 顺着函数调用链去搜索catch入口;
      • 对之前函数调用的栈空间进行退栈处理;
      • 转到搜索到的catch入口。

基于断言的程序调试

  • 一个处于开发阶段的程序可能会含有一些错误(逻辑错误或异常)。
    • 通过测试可以发现程序存在错误。
    • 通过调试可以对错误进行定位。
  • 除了利用调试工具以外,一种常用的调试手段是:
    • 在程序中的某些地方加上一些输出语句,在程序运行时把一些调试信息(如变量的值)输出到显示器。
  • 这种调试手段存在以下问题:
    • 调试者需要对输出的值做一定的分析才能知道程序是否有错。
    • 在开发结束后,去掉调试信息有时是一件很繁琐的工作。
  • 实际上,在调试程序时输出程序在一些地方的某些变量或表达式的值,其目的是为了确认程序运行到这些地方时状态是否正确。
  • 上述目的可以在程序的一些关键或容易出错的点上插入一些断言来表达。
    • 断言(assertion)是一个逻辑表达式,它描述了程序执行到断言处应满足的条件。
    • 如果条件满足则程序继续执行下去,否则程序异常终止。
  • 在程序开发阶段,断言既可以用来帮助开发者发现程序的错误,也可以用于错误定位。

    宏ASSERT

  • C++标准库提供的一个宏assert(在头文件cassert或assert.h中定义),可以用来实现断言。其格式为:
    • assert(<表达式>);
    • <表达式>一般为一个关系/逻辑表达式
  • assert执行时,
    • 如果<表达式>的值为true,程序继续正常执行。
    • 如果<表达式>的值为false,则它会:
      • 首先,显示出相应的表达式、该assert所在的源文件名以及所在的行号等诊断信息;
      • 然后,调用库函数abort终止程序的运行。

        宏ASSERT的实现

  • 宏assert是通过条件编译预处理命令来实现的,其实现细节大致如下:

    1
    2
    3
    4
    5
    6
    7
    8
    //cassert 或 assert.h
    ......
    #ifdef NDEBUG
    #define assert(exp) ((void)0)
    #else
    #define assert(exp) ((exp)?(void)0:<输出诊断信息并调用库函数abort>)
    #endif
    ......
  • 宏assert只有在宏名NDEBUG没定义时才有效,这时,程序一般处于开发、测试阶段。程序开发结束提交时,应该让宏名NDEBUG有定义,然后重新编译程序,这样,assert就不再有效了。

  • 宏名NDEBUG在哪儿定义?
    • 在编译命令中指出。例如:
      cl <源文件1> <源文件2> ... -D NDEBUG ...
    • 在开发环境中的“项目|…属性|C/C++|预处理器|预处理器定义”中指出。(VC++2015)