现代 C++ 编程(一):多态


前言

鉴于我们的编译实验历史性的升级到了 C++17,迈出了走向现代 C++ 非常重要的一步,着实让我这样的 C++ 语言爱好者过了一把瘾。

在努力折磨编译器和被编译器反杀的过程中,愈发觉得 C++ 真是一门神奇的语言,故打算开一个新坑(正好太久没用写博客了),就写写我对现代 C++ 的一些肤浅的理解吧。

本文作为系列第一章,就先来讲讲“熟悉”的面向对象之多态(Polymorphism)吧!

一个阅读提示:在 Java 中,我们一般使用『父类』和『子类』的说法,但是在 C++ 中,我们更倾向于使用『基类』和『派生类』的说法。

虚函数多态

作为一门曾经被称作『C with Class』的语言,面向对象的理念也曾在 C++ 中盛行,时至今日,传统多态其实仍不过时,尽管 C++ 已经更新了很多更加现代的方案了。

虚函数多态的语法也很简单,仅需要把需要被重写(override)的函数在基类中标记为虚函数(virtual)即可。另外,对于需要实现多态的基类,通常还需要将析构函数标记为虚,否则可能造成内存泄露(析构基类指针时,非虚的析构函数不会释放派生类资源)。

下面是传统语法的一个小例子:

#include <iostream>

struct Base
{
    virtual ~Base() = default;
    virtual void foo()
    {
        std::cout << "Base::foo()" << std::endl;
    }
};

struct Derived : public Base
{
    void foo() override
    {
        std::cout << "Derived::foo()" << std::endl;
    }
};

int main()
{
    Base *base = new Derived();
    base->foo(); // 输出 Derived::foo()
    return 0;
}

在上述例子中,我们通过基类指针成功调用了派生类的方法,实现了最基础的多态。

函数指针多态

函数指针多态最初出现在 C 中,由于 C 没有对象的概念,但面向对象兴起后,C 也有实现对象和多态的需求,使用函数指针实现多态是一种常见的做法。

在我们的操作系统课程中就有用到此种做法,在微内核操作系统中,文件设备可能是『磁盘』,『管道』,也可能是『控制台』,因此使用继承和多态是一种很好的方式。有如下代码:

struct Dev {
	int dev_id;
	char *dev_name;
	int (*dev_read)(struct Fd *, void *, u_int, u_int);
	int (*dev_write)(struct Fd *, const void *, u_int, u_int);
	int (*dev_close)(struct Fd *);
	int (*dev_stat)(struct Fd *, struct Stat *);
	int (*dev_seek)(struct Fd *, u_int);
};

在上述代码中,我们声明了 5 个方法(姑且称之为方法)的函数指针,对于每一个派生类,把定义好的函数填入对应函数指针,调用是直接调用函数指针,即实现了多态。

当然 C 的实现看起来既占用空间又麻烦,只是作为 C 实现多态的替代方式。但是在 C++ 中,配合模板可以有更优美的写法。

考虑以下代码:

#include <memory>
#include <iostream>

struct Base
{
    ~Base()
    {
        std::cout << "Base::~Base()" << std::endl;
    }
};

struct Derived : public Base
{
    ~Derived()
    {
        std::cout << "Derived::~Derived()" << std::endl;
    }
};

int main()
{
    {
        std::shared_ptr<Base> p(new Derived());
    } // p is destroyed here
    return 0;
}

给大家几秒钟思考程序输出是什么。

上面的代码中,基类没有使用虚函数,因此不是一个动态多态类,无法在运行期获取类型信息,按照对传统多态语法的理解,直接释放 p 时一定会导致内存泄露。然而,上述程序输出是:

Derived::~Derived()
Base::~Base()

由此可见,shared_ptr 通过某种方式正确的析构了派生类,尽管无法在运行期获取类型信息。

实际上,这里 shared_ptr 的构造函数使用了模板,并保存了派生类的析构方法,一种模仿的实现可以是这样的:

template <typename T>
struct my_pointer
{
    using element_type = T;
    using delete_type = void (*)(T *);
    element_type *ptr;
    delete_type deleter;

    template <typename U>
    explicit my_pointer(U *p)
        : ptr(p), deleter([](T *p) noexcept { delete static_cast<U *>(p); }) {}
    
    ~my_pointer() { deleter(ptr); }
};

int main()
{
    {
        my_pointer<Base> p(new Derived());
    } // p is destroyed here
    return 0;
}

明显的,my_pointer 通过带模板的构造函数,保存了派生类的析构方法,通过这种方式,派生类得以正确析构。

扩展这种语法,就可以一种支持给定操作的“基类”了。

#include <iostream>

struct Foo
{
    int v;

    void print() const noexcept
    {
        std::cout << "(Foo) " << v << std::endl;
    }
};

struct Boo
{
    std::string s;

    void print() const noexcept
    {
        std::cout << "(Boo) " << s << std::endl;
    }
};

class CanPrint
{
    using function_t = void (*)(void *) noexcept;
    void *ptr;
    function_t printer;
    function_t deleter;

public:
    template <typename T>
    explicit CanPrint(T *p)
        : ptr(p),
          printer([](void *p) noexcept
                { reinterpret_cast<T *>(p)->print(); }),
          deleter([](void *p) noexcept
                  { delete reinterpret_cast<T *>(p); })
    {}

    ~CanPrint()
    {
        deleter(ptr);
    }

    void print() const noexcept
    {
        printer(ptr);
    }
};

void print(const CanPrint &x) noexcept
{
    x.print();
}

int main()
{
    CanPrint foo(new Foo{42});
    CanPrint boo(new Boo{"Hello, world!"});
    print(foo);
    print(boo);
    return 0;
}

上述程序正确输出:

(Foo) 42
(Boo) Hello, world!

从性能上来说,使用函数指针只会在运行期进行一次额外的指针寻址操作,相比虚函数查虚表还是会快一些的。另外不使用继承和非侵入式设计也会使得代码更加简洁。不足在于,如果要向下转型则不太方面(传统写法可以 dynamic_cast)。

模板多态

在函数指针一节中,我们使用了模板来获取编译期类型信息,随后保存函数指针待用。虽然函数指针相比虚函数查表要快,但仍然会有运行期的开销(多一层指针寻址),如果不需要存储编译器未知类型的对象,则可以考虑直接使用模板实现编译器多态,使得运行期开销降至 0。

具体实现示例如下:

#include <iostream>

struct Foo
{
    int v;

    void print() const noexcept
    {
        std::cout << "(Foo) " << v << std::endl;
    }
};

struct Boo
{
    std::string s;

    void print() const noexcept
    {
        std::cout << "(Boo) " << s << std::endl;
    }
};

template <typename CanPrint>
auto print(const CanPrint &x) noexcept -> decltype(x.print())
{
    return x.print();
}

int main()
{
    Foo foo{42};
    Boo boo{"Hello, world!"};
    print(foo);
    print(boo);
    return 0;
}

上述程序也可以正确输出:

(Foo) 42
(Boo) Hello, world!

这里函数 print 使用了后置返回值声明直观表达了对参数 x 的要求:可以调用 x.print()。虽然在上述简单的程序中不写也可以,但是如果 print 有针对不同类型的重载版本,写明要求可以触发 SFINAE(Substitution Failure Is Not An Error),避免二义性。

当然,在 C++20 引入 concept 后,上述写法可以进一步改进:

template <typename T>
concept CanPrint = requires (T t) { t.print(); }; 

template <CanPrint T>
auto print(const T &x) noexcept
{
    return x.print();
}

模板多态还有另一种稍显复杂的写法:

#include <iostream>

template <typename T>
struct CanPrint
{
    void print() const noexcept
    {
        static_cast<const T*>(this)->print();
    }
};

struct Foo : CanPrint<Foo>
{
    int v;

    explicit Foo(int v) : v{v} {}

    void print() const noexcept
    {
        std::cout << "(Foo) " << v << std::endl;
    }
};

struct Boo : CanPrint<Boo>
{
    std::string s;

    explicit Boo(std::string s) : s{std::move(s)} {}

    void print() const noexcept
    {
        std::cout << "(Boo) " << s << std::endl;
    }
};

template <typename T>
void print(const CanPrint<T> &obj) noexcept
{
    obj.print();
}

int main()
{
    Foo foo{42};
    Boo boo{"Hello, world!"};
    print(foo);
    print(boo);
    return 0;
}

这种写法对类的要求更加严格,更不容易发生二义性,不过需要侵入式的设计。这种设计参考了 Rust 的 traits。不过在 C++20 引入 concept 后这种写法的意义就不大了。

还有另外一种写法也是仿造 Rust traits 的实现的:

#include <variant>
#include <iostream>

struct Foo
{
    int v;
    void print() const noexcept
    {
        std::cout << "(Foo) " << v << std::endl;
    }
};

struct Boo
{
    std::string s;
    void print() const noexcept
    {
        std::cout << "(Boo) " << s << std::endl;
    }
};

template <typename T>
struct print_traits
{
    using print_return_t = decltype(std::declval<T>().print());
};

template <typename T, typename traits = print_traits<T>>
auto print(const T &obj) noexcept -> typename traits::print_return_t
{
    return obj.print();
}

int main()
{
    Foo foo{42};
    Boo boo{"Hello, world!"};
    print(foo);
    print(boo);
    print(1); // error: print_traits<int> has no type member print_return_t
    return 0;
}

这种写法常见于标准库中(当然我这个 traits 是简化实现,标准库为了使报错信息更友善,会写的较为复杂),且头文件 type_traits 包括了大量标准库定义的 traits。其本质也是限制模板参数。

std::variant 多态

在 C++17 中,std::variantstd::any 横空出世,他们神奇之处是可以存储若干毫不相干的类型并记录类型信息,使得多态和向下转型都可以不依赖虚表的更好的实现。

以下是借助 std::variant 实现多态和向下转型的一个小型示例:

#include <vector>
#include <variant>
#include <iostream>
struct Foo
{
    int v;
    void print() const noexcept
    {
        std::cout << "(Foo) " << v;
    }
};

struct Boo
{
    std::string s;
    void print() const noexcept
    {
        std::cout << "(Boo) " << s;
    }
};

using CanPrint = std::variant<Foo, Boo>;

template<class>
inline constexpr bool always_false_v = false;

void print(const CanPrint &obj) noexcept
{
    std::visit([](const auto& obj) noexcept { obj.print(); }, obj);
}

void self_double(CanPrint &obj) noexcept
{
    return std::visit([](auto& obj) noexcept { 
        using T = std::decay_t<decltype(obj)>;
        if constexpr (std::is_same_v<T, Foo>)
            obj.v *= 2;
        else if constexpr (std::is_same_v<T, Boo>)
            obj.s += obj.s;
        else 
            static_assert(always_false_v<T>, "non-exhaustive visitor!");
    }, obj);
}

void print(const std::vector<CanPrint> &vec)
{
    size_t i = 0;
    std::cout << "{";
    for (const auto &obj : vec) {
        print(obj);
        std::cout << (++i == vec.size() ? "" : ", ");
    }
    std::cout << "}\n";
}

int main()
{
    std::vector<CanPrint> vec{Foo{1}, Boo{"hello"}, Foo{42}, Foo{59}, Boo{"world"}};
    print(vec);
    for (auto &obj : vec)
        self_double(obj);
    print(vec);
    return 0;
}

上述代码输出:

{(Foo) 1, (Boo) hello, (Foo) 42, (Foo) 59, (Boo) world}
{(Foo) 2, (Boo) hellohello, (Foo) 84, (Foo) 118, (Boo) worldworld}

通过 std::visit,上述代码实现了 std::variant 多态。当然 std::variant 还有很多其他方法获取存储类型并进行多态操作,上述代码只举了最简单的例子。

后记

今天的笔记就到这里吧,欢迎批评指正和提供更好的思路。
–63189dedd7623ed448e0fafc3990d2c6–


评论
  目录