前言
鉴于我们的编译实验历史性的升级到了 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::variant
和 std::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–