C++关键概念 泛型编程 模板的元编程 印第安纳 得克萨斯

C++关键概念 泛型编程

泛型编程是C++中的一项关键概念,它允许程序员在定义数据结构、算法或类时使用类型参数。这些类型参数可以是任何数据类型,如整数、浮点数、字符串等。通过使用泛型,程序员可以编写出更加灵活、可重用的代码,而不需要为每种数据类型都编写特定的代码。

泛型编程的实现主要依赖于模板这一关键技术。模板是一种将数据类型作为一个参数的特殊函数或类,它可以在编译时根据实际的数据类型生成相应的代码。通过使用模板,程序员可以为不同类型的数据提供统一的处理逻辑。

在C++中,泛型编程主要通过模板来实现。程序员可以自定义模板类和模板函数,以满足特定的需求。这个过程称为模板实例化。当编译器遇到模板代码时,它会使用推断出的模板参数来创建一个特定版本的函数或类实例。整个实例化过程发生在编译阶段。

此外,泛型编程支持隐式接口,这是基于有效的表达式推断出来的。这与面向对象编程支持的显式接口有所不同,显式接口由函数名称、参数类型、返回类型构成。

值得注意的是,泛型类或泛型接口中的函数,控制函数重载的规则是相同的。同时,类型参数要遵循的限制与类和接口中的类型参数相同。

总的来说,泛型编程为C++程序员提供了一种强大的工具,使他们能够编写出更加灵活、可重用和高效的代码。通过使用模板和类型参数,程序员可以创建出能够处理多种数据类型的算法和数据结构,从而提高了代码的可维护性和可扩展性。

对 C++ 来说,泛型编程和使用模板的元编程已经取得了巨大的成功。但是,对泛型组件的接口却迟迟未能以一种令人满意的方式进行合适的规范

例如,在 C++98 中,标准库算法大致是如下规定的:

template<typename Forward_iterator, typename Value>
ForwardIterator find(Forward_iterator first, Forward_iterator last,
                              const Value & val)
{
   
    while (first != last && *first != val)
        ++first;
    return first;
}

C++ 标准规定:

  • 第一个模板参数必须是前向迭代器。
  • 第二个模板参数类型必须能够使用 == 与该迭代器的值类型进行比较。
  • 前两个函数参数必须标示出一个序列。

这些要求是隐含在代码中的:编译器所要做的就是在函数体中使用模板参数。结果是:极大的灵活性,对正确调用生成出色的代码,以及对不正确的调用有糟糕得一塌糊涂的错误信息。解决方案显而易见,将前两项条件作为模板接口的一部分来指定:

template<forward_iterator Iter, typename Value>
    requires equality_comparable<Value, Iter::value_type>
forward_iterator find(Iter first, Iter last, const Value& val);

这大致就是 C++20 所提供的了。注意 equity_comparable 概念,它捕获了两个模板参数之间必需有的关系。这样的多参数概念非常常见。

表达第三个要求([first:last) 是一个序列)需要一个库扩展。C++20 在 Ranges 标准库组件(§9.3.5)中提供了该特性:

template<range R, typename Value>
    requires equality_comparable<Value, Range::value_type>
forward_iterator find(R r, const Value& val)
{
   
    auto first = begin(r);
    auto last = end(r);
    while (first!=last && *first!=val)
        ++first;
    return first;
}

为了规范模板对其参数的要求,对其提供良好支持,有过数次尝试。本节会进行描述:

  • §6.1:概念的早期历史
  • §6.2:C++0x 中的概念
  • §6.3:Concepts TS
  • §6.4:C++20 中的概念

6.1 概念的早期历史

1980 年,我猜想泛型编程可以通过 C 风格的宏来有效支持 [Stroustrup 1982]。然而我完全错了。一些有用的简单泛型抽象能通过这种方法表达,1980 年代的标准化之前的 C++ 通过 <generic.h> 中的一组宏为泛型编程提供支持,但宏在大型项目或广泛使用的情况下无法有效管理。尽管泛型编程在当时流行的“面向对象的思想”中并没有一席之地,我确实发现了一个问题,需要解决它才能达到我对“带类的 C”的目标。

大约在 1987 年,我尝试设计具有合适接口的模板 [Stroustrup 1994],但失败了。我需要三个基本属性来支持泛型编程:

  • 全面的通用性/表现力——我明确不希望这些功能只能表达我想到的东西。
  • 与手工编码相比,零额外开销——例如,我想构建一个能够与 C 语言的数组在时间和空间性能方面相当的 vector。
  • 规范化的接口——我希望类型检查和重载的功能与已有的非泛型的代码相类似。

那时候没人知道如何做到全部三个方面,因此 C++ 所做到的是:

  • 图灵完备性 [Veldhuizen 2003]
  • 优于手动编码的性能
  • 糟糕的接口(基本上是编译期鸭子类型),但仍然做到了静态类型安全

前两个属性使模板大获成功。

由于缺乏规范化的接口,我们在这些年里看到了极其糟糕的错误信息,到了 C++17 还仍然是这样。缺乏规范化的接口这一问题,让我和很多其他人困扰很多年。它让我非常困扰的原因是,模板无法满足 C++ 的根本的设计标准 [Stroustrup 1994]。我们(显然)需要一种简单的、没有运行期开销的方法来指定模板对其模板参数的要求。

多年以来,一些人(包括我)相信模板参数的要求可以在 C++ 本身中充分指定。1994 年,我在 [Stroustrup 1994] 中记录了基本的想法,并在我的网站上发布了示例 [Stroustrup 2004–2020]。自 2006 年以来,基于 Jeremy Siek 的作品,Boost 提供了该想法的一个变体,Boost 概念检查库 [Siek and Lumsdaine 2000–2007]。不知何故,它并未像我所希望的那样广泛流行。我怀疑原因是它不够通用、不够优雅(Boost 感到有义务使用宏隐藏细节),并且在标准中不受支持。许多人将其视为一种奇技淫巧。

为 C++ 定义的概念可以追溯到 Alex Stepanov 在泛型编程上的工作,这是 1970 年代末开始的,一开始用的名称是“代数结构” [Kapur et al. 1981]。注意,那差不多比 Haskell 的类型类设计 [Wadler and Blott 1989] 要早十年,比我尝试解决 C++ 的类似问题要早 5 年。对于这种需求,Alex Stepanov 早在 1990 年代末期的讲座中就使用了“概念”这一名称,并记录在 [Dehnert and Stepanov 2000]。我之所以提到这些,是因为许多人猜测概念是从 Haskell 类型类派生而来但被错误命名了。Alex 使用“概念”这一名称是因为概念此处用来代表应用领域(如代数)中的基本概念。

目前把概念当作依靠使用模式来描述操作的类型谓词,这起源于二十一世纪初期 Bjarne Stroustrup 和 Gabriel Dos Reis 的工作,并记录在 [Dos Reis and Stroustrup 2005b, 2006; Stroustrup and Dos Reis 2003b, 2005a] 之中。这种方法在 1994 年的《设计和演化》[Stroustrup 1994] 一书也被提及,但是我不记得我第一次进行尝试的时间了。将概念建立于使用模式的主要原因是为了以一种简单而通用的方式处理隐式转换和重载。我们了解 Haskell 类型类,但它们对当前的 C++ 设计影响不大,因为我们认为它们太不灵活了。

精确指定并检查一个模板对于参数的要求曾经是 C++0x 的最出彩之处,会对泛型编程提供关键支持。可是,它最终甚至没能进入 C++17。

Bjarne Stroustrup 和 Gabriel Dos Reis 在 2003 年发表的论文 [Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 明确指出,概念是简化泛型编程的宏伟计划的一部分。例如,一个 concept 可以被定义为一组使用模式的约束,就是说,作为对某种类型有效的语言构件 [Stroustrup and Dos Reis 2003b]:

concept Value_type {
   
    constraints(Value_type a)
    {
   
        Value_type b = a;      // 拷贝初始化
        a = b;                 // 拷贝赋值
        Value_type v[] = {
   a};  // 不是引用
    }
};

template<Value_type V>
    void swap(V& a, V& b);  // swap() 的参数必须是值类型

但是,当时的语法和语义还很不成熟。我们主要是试图建立设计标准 [Stroustrup and Dos Reis 2003a]。从现代(2018 年)的角度来看,[Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 有很多缺陷。但是,它们为概念提供了设计约束,并在以下方面提出了建议:

  • 概念——用于指定对模板参数要求的编译期谓词。
  • 根据使用模式来指定原始约束——以处理重载和隐式类型转换。
  • 多参数概念——例如 Mergeable<In1,In2,Out>
  • 类型和值概念——也就是说,概念既可以将值也可以将类型当作参数,例如 Buffer<unsigned char,128>
  • 模板的“类型的类型”简略写法—例如 template<Iterator Iter> …
  • “模板定义的简化写法”——例如 void f(Comparable&); 使泛型编程更接近于“普通编程”。
  • auto 作为函数参数和返回值中约束最少的类型。
  • 统一函数调用(§8.8.3)——减少泛型编程与面向对象编程之间的风格差异问题(例如 x.f(y)f(x,y)x+y)。

奇怪的是,我们没有建议通用的 requires 子句(§6.2.2)。这些都是后面所有概念变体的一部分。

6.2 C++0x 概念

2006 年,基本上每个人都期望 [Gregor et al. 2006; Stroustrup 2007] 中所描述的概念版本会成为 C++09 的一部分,毕竟它已经投票进入了 C++ 标准草案(工作文件)。但是,C++0x 变成了 C++11,并且在 2009 年,概念因复杂性和可用性问题陷入困境 [Stroustrup 2009a,b],委员会以绝对多数票一致同意放弃概念设计 [Becker 2009]。失败的原因多种多样,而且可能使我们获得在 C++ 标准化努力之外的教训。

在 2004 年,有两项独立的工作试图将概念引入 C++。因为主要支持者分别来自印第安纳大学和得克萨斯农工大学,这两派通常就被称为“印第安纳”和“得克萨斯”:

  • 印第安纳:一种与 Haskell 类型类相关的方法,主要依赖于操作表来定义概念。这派认为,程序员应当显式声明一个类型“模拟”了一个概念;也就是说,该类型提供了一组由概念指定的操作 [Gregor et al. 2006]。关键人物是 Andrew Lumsdaine(教授)和 Douglas Gregor(博士后和编译器作者)。
  • 得克萨斯:一种基于编译期类型谓词和谓词逻辑的方法。这派认为,可用性很重要,因而程序员不必显式指定哪些类型与哪些概念相匹配(这些匹配可以由编译器计算)。对于 C++,优雅而有效地处理隐式转换、重载以及混合类型的表达式被认为是必需的 [Dos Reis and Stroustrup 2006; Stroustrup and Dos Reis 2003b]。关键人物是 Bjarne Stroustrup(教授)和 Gabriel Dos Reis(博士后,后来成为教授)。

根据这些描述,这些方法似乎是不可调和的,但是对于当时的参与人员而言,这并不明显。实际上,我认为这些方法在理论上是等效的 [Stroustrup and Dos Reis 2003b]。该论点的确可能是正确的,但对于 C++ 上下文中的详细语言设计和使用的实际影响并不等同。另外,按照委员会成员的解释,WG21 的共识流程强烈鼓励合作和联合提案,而不是在竞争性的提案上工作数年,最后在它们之间进行大决战(§3.2)。我认为后一种方法是创造方言的秘诀,因为失败的一方不太可能放弃他们的实现和用户,并就此消失。请注意,上面提到的所有的人在一起与 Jeremy Siek(印第安纳的研究生和 AT&T 实验室的暑期实习生)和 Jaakko Järvi(印第安那的博士后,得州农工大学教授)是 OOPSLA 论文的合著者,论文展示了折中设计的第一个版本。印第安纳和得克萨斯的团体从未完全脱节,我们为达成真正的共识而努力。另外,从事这项工作之前,我已经认识 Andrew Lumsdaine 很多年。我们确实希望折中方案能够正常工作。

在实现方面,印第安纳的设计的进度远远领先于得克萨斯的设计的进度,并且具有更多人员参与,所以我们主要基于此进行。印第安纳的设计也更加符合常规,基于函数签名,并且与 Haskell 类型类有明显相似之处。考虑到涉及的学术界人士的数量,重要的是印第安纳的设计被视为更符合常规并且学术上更为得体。看来我们“只是”需要

  • 使编译器足够快
  • 生成有效的代码
  • 处理重载和隐式转换。

这个决定使我们付出了三年的辛勤工作和许多争论。

C++0x 概念设计在 [Gregor et al. 2006; Stroustrup 2007] 中得到阐述。前一篇论文包含一个标准的学术“相关工作”部分,将这个设计与 Java、C#、Scala、Cecil、ML、Haskell 和 G 中的工具进行比较。在这里,我使用 [Gregor et al. 2006] 中的例子进行总结。

6.2.1 概念定义

概念被定义为一组操作和相关类型:

concept EqualityComparable<typename T> {
   
    bool operator==(const T& x, const T& y);
    bool operator!=(const T& x, const T& y) {
    return !(x==y); }
}

concept InputIterator<typename Iter> {
   
    // Iter 必须有 value_type 成员:
    typename value_type = Iter::value_type;
    // ...
}

某些人(印第安纳)认为概念和类之间的相似性是一种优势。

但是,概念中指定的函数并不完全类似于类中定义的函数。例如,在一个 class 中定义的运算符具有隐式参数(“this”),而 concept 中声明的运算符则没有。

将概念定义为一组操作的方法中存在一个严重的问题。考虑在 C++ 中传递参数的方式:

void f(X);
void f(X&);
void f(const X&);
void f(X&&);

暂时不考虑 volatile,因为它在泛型代码参数中很少见到,但是我们仍然有四种选择。在一个 concept 中,我们是否

  • f 表示为一个函数,用户是否为调用选择了正确的参数?
  • 是否重载了 f 的所有可能?
  • f 表示为一个函数,并要求用户定义一个 concept_map§6.2.3)映射到 f 的所需的参数类型?
  • 语言是否将用户的参数类型隐式映射到模板的参数类型?

对于两个参数,我们将有 16 种选择。尽管很少有三个参数泛型函数,但是这种情况我们会有 4*4*4 种选择。变参模板会如何呢?我们会有 4N 种选择,如(§4.3.2)。

传递参数的不同方式的语义并不相同,因此我们自然而然地转向接受指定的参数类型,将匹配的负担推到了类型设计者和 concept_maps 的作者(§6.2.3)。

类似地,我们到底是在为

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

EwenWanW

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值