[读书笔记]《Hands on Design Patterns with C++》——类,继承,多态,模板

[读书笔记]《Hands on Design Patterns with C++》——类,继承,多态,模板,第1张

前言

《Hands on Design Patterns with C++》首先这本书不是跟之前的书籍一样只是重点在经典的 23 种“设计模式” 上,这些经典的设计模式当然可以使用 C++ 语言来实现,但是 C++ 强大的地方在于其泛型编程能力。

而且设计模式一般是在软件设计中比较有挑战性的场景下, 提出的一些解决方案。

一个是问题场景,一个是对应提出的解决方案。

随着时间的推移,一些场景中,会有更好地解决方案被提出,同时呢,也会遇到新的挑战。

本书显式介绍了 C++ 的一些特性,并且结合这些特性以及它们可以解决的问题,对设计加以说明,并且会提到一些利用泛型编程对这些模式的改进。

个人读下来前言,之前大部分的设计模式书籍,都是围绕经典的 GoF 23 种设计模式来的,以此来介绍这些设计模式的思想,并且示意它们在软件结构上的特征。

然后这里的要素就是,它们都是在特定场景下,提出的被认可的解决方案。

软件越来越复杂,场景也会越来越复杂,并且语言特性也在不断的发展,结合泛型编程,一些设计模式可以加以改进。

更多地是如何将一些设计模式,或者说类似的思想用泛型编程来实现。

所以书中也会介绍一些 c++11 ~ 17 的新特性。

首先是介绍 C++ 中比较一些比较经典的设计模式。

以及一些泛型编程下的新变体。

同时兼顾使用一些新的语言特性来实现这些内容。

主要是介绍 how(如何使用) 和 why(为什么有用)。

个人也是阅读的过程中学习作者对 why 的描述,能对背后的原因了解更多一点。

思考的角度受益匪浅。

第一章 继承和多态 What are classes and what is their role in C++?

类主要是包含了数据以及 *** 作这些数据的方法,体现它们之间的关系。

类属于用户自定义的数据类型。

类的封装是 C++ 的重点。

其允许我们限定数据及相关方法的访问权限(public,private)等。

这里对数据和成员函数访问权限的控制,决定了在类外,用户可以“看到” 这个类的哪些内容。

(这里当然大家都能理解,但是类的设计也是体现了代码者的设计思想在里面的,我希望暴露什么,隐藏什么。

public 更像是一种 contract,一旦报错不会随意修改。

而 private 的数据和函数则更多地是实现的一部分,只要 public 接口不变,这些内容可以更改的。

What are class hierarchies and how does C++ use inheritance?

类能够继承主要有两个目的,一个是允许我们表达对象之间的关系,另一方面可以让我们用简单的类型组合出更复杂的类型。

子类是在父类基础上的扩展。

类之间有 is a 和 has a 的关系。

继承主要方式有公开继承 Public 和私有继承 private。

公开继承继承的是基类的 public 接口,相当于基类有的 Public 接口,承诺有的功能, public 继承的子类也会自动被这样要求,所以在任何可以使用基类的地方,都应该可以用一个子类来替换。

这里就是表达了 is - a 的概念。

一个子类的实例也是一个基类的实例。

当然,有的时候在 C++ 中表达 is a 的实例并不是那么的直观, 例如经典的概念上正方形是一个矩形,但是实现上,更应该用矩形的类类继承正方形的类。

如果一个名为 Bird 的基类有 fly 这样的接口(表示会飞的鸟类),那么就不能让 penguin 企鹅子类继承这个基类,需要一个更加抽象的基类,然后继承两个子类,分别代表会飞的子类,不会飞的子类。

这样才更合适。

所以上面的描述,主要是想体现,在 C++ 中,是否可以与概念上的类关系一致,要看我们如何设计基类,更确切的说,如何设计类的 public 接口。

因此子类与父类的实例之间有转换关系。

class Base{};
class Derived : public Base {};

Base* b = new Derived;
Derived* d = b; // wrong
Derived* d = static_cast<Derived*>(b); // OK

私有继承则不会继承基类的任何接口,只继承了它的实现,一般都是使用基类的实现来完成子类自己的算法,这种模式是 has - a 的概念,基本与 组合 一致。

What is runtime polymorphism and how is it used in C++?

多态支持同一个接口,根据需求实现不同的功能。

通过虚函数来实现多态。

另一种表述就是可以通过基类指针访问到子类的内容。

虚函数的 override 是通过函数有相同的参数和返回类型。

这里可以注意,当返回类型是引用或者指针的时候,override 可以返回子类的指针。

即下面的代码是可以正常运行的。

class Base {
public:
 virtual Base* fly() {
     return new Base;
 }
};

class Derived : public Base {
public:
    Derived* fly() override{   //  这里直接返回 Base* 也是可以的
        return new Derived;
    }
};

Base* b = new Derived;
Derived* d = static_cast<Derived*>(b);
d->fly();

虚函数的特殊形式就是纯虚函数。

一般包含纯虚函数的基类也叫做抽象基类,继承它的子类必须都要实现纯虚函数。

不能创建抽象基类的对象,但是可以创建抽象基类的指针,还要通过这个指针实现多态呢。

为了防止继承覆写时,人为因素拼写错误,导致函数覆写失败,加上 c++ override 关键字则编译器会帮忙检查子类虚函数的声明是否与基类一致。

另外基类与子类之间的转换,前面强制转换使用的是 static_cast, 但是这里要求你需要知道正确的子类类型,否则即使代码正常运行,最后得到的结果也可能不是我们想要的。

对于有虚函数的基类,可以使用 dynamic_cast,来帮助我们确认基类是否可以转换成我们想要的子类类型,如果不行指针它会返回 Nullptr,引用则会直接报错。

上面介绍的都是子类只继承一个基类,还可以同时继承多个基类,即多重继承,这里仍然以 public 继承做说明。

当多重继承是,基类需要同时满足所有基类的 contract。

当两个基类定义了同名的成员函数,并且子类都没有重新实现这个同名函数,则会编译错误,如果是虚函数并且子类覆写了它,则就是正常的多态行为。

当多重继承时,不同类型的基类也可以通过子类和 dynamic_cast 进行 cross-cast。

但是现在一般 不太建议使用多重继承,主要是很难通过多重继承来设计出一个清晰的关系。

class Base1{};
class Base2{};

class Derived: public Base1, public Base2{};

Base1* b1 = new Derived;
Base2* b2 = dynamic_cast<Base2*>(b1); // OK

本章思考题:

  • What is the importance of objects in C++?
  • What relation is expressed by public inheritance?
  • What relation is expressed by private inheritance?
  • What is a polymorphic object?
第二章 类和函数模板

Templates in C++

C++ 语言的一大优势就是泛型编程。

其实现方式就是采用模板来实现了。

Class and function templates

首先模板函数,除了虚函数之外,无论是普通函数还是类的成员函数都可以是模板函数。

模板类型 T 不仅可以用来声明函数的参数,还可以在函数体中使用。

template <typename T>
T add(T x) {
    T from = x;
    return from+1;
}

类模板中,模板参数 T 一般是用来声明它的成员变量,也可以声明函数和函数中的局部变量。

template <typename T>
class ArrayOf2 {
public:
  T& operator[](size_t i) {return a_[i];}
  const T& operator[](size_t i) const {return a_[i];}
  T sum() const {return a_[0] + a_[1];}
private:
  T a_[2];
};

ArrayOf2<int> i;
i[0] = 1; i[1] = -4;
auto s = i.sum();  // s == -3

注意这里模板只有我们用到的时候才会实例化相关代码。

还是以前面的 ArrayOf2 模板类为例,当 T 为指针时,调用 sum() 就会有问题,但是只要我们不调用,代码就还是安全的。

ArrayOf2<char *> i;
char s[] = "Hello";
i[0] = s;
i[1] = s + 2;
// 知道这里代码都是可以正常编译的

auto x = i.sum() // 编译错误

前面介绍的模板类型都是表示 数据类型。

C++ 也允许非数据类型的模板参数。

  • 非数据类型可以是整数或者是枚举类型值。

    这时用来初始化非数据类型的模板参数一定得是编译期常量,或者是 constexpr 常量表达式。

    例如下面:

template <typename T, size_t N>
class IArray
{
public:
    T &operator[](size_t i) {
        if (i > N)
            throw std::out_of_range("Bad insex");
        return data_[i];
    }

private:
    T data_[N];
};

IArray<int, 5> arr; // OK
cin >> arr[0];
IArray<int, arr[0]> arr1; // Wrong
  • 非数据类型也可以是模板参数。

    又叫 template template parameter.

template <typename T>
using Deq = std::deque<T, std::allocator<T> >;

template <typename T>
using Vec = std::vector<T, std::allocator<T> >;

template <template <typename> class Out_container,
          template <typename> class In_container,
          typename T>
Out_container<T> resequence(const In_container<T> &in_container)
{
    Out_container<T> out_container;
    for (auto x : in_container)
    {
        out_container.push_back(x);
    }
    return out_container;
}

std::vector<int> v{1, 223, 33, 4, 5};
auto d = resequence<Deq, Vec>(v); // 注意这里不能直接传 deque 和 vector

参考:https://www.zhihu.com/zvideo/1282044919448715264

参考:https://blog.csdn.net/wangdamingll/article/details/54019506

Template instantiations 模板实例化

  • 实例化模板函数主要是让模板根据输入的实际数据来推导类型,当推导的类型发生冲突时该如何处理的。

template <typename T>
T cmp(T& x, T& y) {
    return x > y ? x : y;
}

auto re = cmp(2l, 3); // 冲突,会编译出错
  • 实例化模板类的话,会直接实例化所有的成员变量,但是只有当模板成员函数使用到的时候才会实例化。

Template specializations 模板特化

这里以类模板为例, 假设有这样一个类模板:

template <typename U, typename V>
class Ratio {
public:
 Ratio() : num_(), denom_() {}
 Ratio(const U& num, const V& denom) : num_(num), denom_(denom) {}
 explicit operator double() const {  // 类型转换函数
     return double(num_) / double(denom_);
 }
 private:
  U num_;
  V denom_;
};
  • 全特化

全特化是把所有的模板参数用特定的类型来替代,这里创建了一个同名的函数模板或者类模板的实例,但是可以覆写(override)里面的实现,所以实现可以完全不一样。

例如下面,对模板类 Ratio 进行了全特化,并且改写了其内部实现:

template<>
class Ratio<double, double> {
public:
    Ratio():value_() {}
    template <typename U, typename V>
    Ratio(const U& num, const V& denom) : 
    value_(double(num) / double(denom)) {}
    explicit operator double() const {
        return value_;
    }
private:
    double value_;
};

粒度可以自己把控,如果大部分代码都是可以复用,只是想重新实现一个成员函数时也可以:

template<>
Ratio<double, double>::operator double() const { return demon_/num_; }
  • 偏特化

即只确定模板参数的一部分,保留一部分泛型,并且同样的可以在内部覆写前面的实现。

注意这里会存在一个可能会实例化失败的情况,还是以 Ratio 模板类为例,其中可以偏特化 U:

template <typename V>
class Ratio<double, V> {  // 偏特化 U
public:
 Ratio() : value_() {}
 Ratio(const double& num, const V& denom) : value_(num / double(denom)) {}
 explicit operator double() { return value_;}
private:
  double value_;
};

Ratio r; 这时会优先匹配上面的偏特化版本,因为只需要推导一个模板参数。

同时我们还可以同时再写一个偏特化 V 的版本,

template <typename U>
class Ratio<U, double> { // 偏特化 V
public:
 Ratio() : value_() {}
 Ratio(const U& num, const double& denom) : value_(double(num) / denom) {}
 explicit operator double() { return value_;}
private:
  double value_;
};

当我们想实例化一个 Ratio 的时候,两个偏特化都可以推导成需要的形式,代码是编译不过的,因为编译器也不知道该选择哪个实现。

唯一解决的方法是我们再明确给出一个 Ratio 的特例化版本。

Overloading of template functions

普通函数的也有重载机制,有时会存在多个可能的结果,一般隐式变化的成本 增加 const 或者移除引用 > 内置类型转换 > 子类转成父类。

如果是多参数的函数:

void test(long a, long b, int c) {}
void test(int a, int b, double c) {}

double c = 1.0;
test(2l,3l, c); // 编译报错

虽然匹配第一个定义只需要隐式转换 int 到 double, 而匹配第二个定义需要将前两个 double 转换到 int, 看似匹配第一个更优,但是编译器还是会报错。

在函数中引入模板参数,则让模板函数的重载机制更加复杂了。

不过基本准则是:

  • 如果有一个非模板函数近乎完美的匹配上了,则优先匹配这个非模板函数
  • 如果没有,则模板函数会尝试实例化一个能近乎完美匹配的函数

Variadic templates

C++11 之后,还介绍了可变参数模板,我们可以用可变参数模板来声明一个能接受任意数量参数的函数了:

template <typename ... T>
auto sum(const T& ... x);

下面可以实现求和功能:

template <typename T1>
auto sum(const T1 &t1)
{
    return t1;
}

template <typename T1, typename... T>
auto sum(const T1 &t, const T &...args)
{
    return t + sum(args...); 
}

函数实际调用中的…运算符,它表示参数包扩展,此时会对args解包,展开各个参数,并用逗号分隔, 这样就又会调用 auto sum(const T1 &, const T &…), 知道解到最后一个,直接返回,完成求和 *** 作。

同理也可以使用可变模板来创建一个可变模板类,同样需要一个特例化的单参数类。

Lambda expressions

正常在 C++ 中带有函数功能的一般都是可调用的,正常函数或者是仿函数。

一般情况下在一些局部地方定义一些可调用实体,紧挨着能使用到它的地方。

但是 C++ 中不允许一个函数中定义另一个函数。

如果中间间隔太远那么就不是很方便。

一个绕过的方式是在函数中声明一个可调用的类。

void do_work(){
  vector<int> v;
  ...
  struct compare{
    bool operator() (int x, int y) {return x < y;}
  };
  sort(v.begin(), v.end(), compare());
}

这样结构足够紧凑可行,但是太冗长了,一些变量需要重复书写。

我们并不真正需要给这个类一个名字,我们只想这样一个类的对象。

lambda 表达式就是这样一个东西:

void do_work(){
  vector<int> v;
  ...
  auto compare = [](int x, int y){return x < y;}
  sort(v.begin(), v.end(), compare);
}

返回类型一般由编译器去推导,所以需要配合 auto 一起使用。

lambda 表达式是一个对象,所以它们可以有数据成员,当然一个可调用的局部类也可以有数据成员:

// 不使用 lambda 表达式
void do_work(){
  vector<int> v;
  ...
  struct compare_with_tolerance{
    const double tolerance;
	  explicit compare_with_tolerance(double tol) : tolerance(tol){}
    bool operator() (double x, double y) {
		return x < y && std::abs(x - y) > tolerance;}
  };
  double tolerance = 0.01;
  sort(v.begin(), v.end(), compare_with_tolerance(tolerance));
}
// 使用 lambda 表达式,简洁很多
void do_work(){
  vector<int> v;
  ...
  double tolerance = 0.01;
  auto compare_with_tolerance = [=](auto x, auto y) {
		return x < y && std::abs(x - y) > tolerance;}
  };
  sort(v.begin(), v.end(), compare_with_tolerance);
}

具体关于 lambda 表达式 cature 局部变量的方式属于用法的内容。

lambda 表达式是对象,不是函数,所以就没有一个函数的重要性质—— 重载。

但是对象是一个类的示例,类可以继承,而多重继承,如果多个基类中有同名的函数,那么会有重载的效果,如果完全一致就会编译报错。

所以这里使用可变参数类模板来实现 lambda 表达式的重载形式,然后使用可变参数函数来调用可变参数类模板实现。

具体比较麻烦,这里先不展开说明了。

本章思考题:

  1. What is the difference between a type and a template?
  2. What kind of templates does C++ have?
  3. What kinds of template parameters do C++ templates have?
  4. What is the difference between a template specialization and a template
    instantiation?
  5. How can you access the parameter pack of the variadic template?
  6. What are lambda expressions used for?

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/674045.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-19
下一篇 2022-04-19

发表评论

登录后才能评论

评论列表(0条)

保存