14-17 Constexpr 聚合和类

在第F.1课——常量表达式函数中,我们介绍了常量表达式函数,这类函数可在编译时或运行时进行求值。例如:

#include <iostream>

constexpr int greater(int x, int y)
{
    return (x > y ? x : y);
}

int main()
{
    std::cout << greater(5, 6) << '\n'; // greater(5, 6) may be evaluated at compile-time or runtime

    constexpr int g { greater(5, 6) };  // greater(5, 6) must be evaluated at compile-time
    std::cout << g << '\n';             // prints 6

    return 0;
}

image

在此示例中,greater() 是 constexpr 函数,而 greater(5, 6) 是常量表达式,可在编译时或运行时求值。由于 std::cout << greater(5, 6) 在非 constexpr 上下文中调用 greater(5, 6),编译器可自由选择在编译时或运行时求值 greater(5, 6)。当 greater(5, 6) 用于初始化常量表达式变量 g 时,该调用处于常量表达式上下文中,必须在编译时进行求值。

现在考虑以下类似示例:

#include <iostream>

struct Pair
{
    int m_x {};
    int m_y {};

    int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    Pair p { 5, 6 };                  // inputs are constexpr values
    std::cout << p.greater() << '\n'; // p.greater() evaluates at runtime

    constexpr int g { p.greater() };  // compile error: greater() not constexpr
    std::cout << g << '\n';

    return 0;
}

image

在此版本中,我们定义了一个名为Pair的聚合结构体,其中greater()已成为成员函数。然而,由于成员函数greater()并非constexpr,p.greater()便不属于常量表达式。当std::cout << p.greater()调用p.greater()时(在非constexpr上下文中),p.greater()将在运行时进行求值。然而当尝试用 p.greater() 初始化常量表达式变量 g 时,会因 p.greater() 无法在编译时求值而引发编译错误。

由于 p 的输入参数(5 和 6)均为常量表达式值,p.greater() 似乎应当支持编译时求值。但具体该如何实现呢?


常量表达式成员函数

与非成员函数类似,成员函数可通过使用 constexpr 关键字实现常量表达式特性。常量表达式成员函数可在编译时或运行时进行求值。

#include <iostream>

struct Pair
{
    int m_x {};
    int m_y {};

    constexpr int greater() const // can evaluate at either compile-time or runtime
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    Pair p { 5, 6 };
    std::cout << p.greater() << '\n'; // okay: p.greater() evaluates at runtime

    constexpr int g { p.greater() };  // compile error: p not constexpr
    std::cout << g << '\n';

    return 0;
}

image

在此示例中,我们将 greater() 声明为 constexpr 函数,因此编译器可在运行时或编译时对其进行求值。

当我们在运行时表达式中调用 p.greater()(如 std::cout << p.greater())时,它会在运行时进行求值。

然而,当使用 p.greater() 初始化常量表达式变量 g 时,会引发编译器错误。尽管 greater() 现为常量表达式,但 p 仍非常量表达式,因此 p.greater() 并非常量表达式。


常量表达式聚合体 Constexpr aggregates

好的,既然需要 p 成为常量表达式,那就直接将其定义为常量表达式:

#include <iostream>

struct Pair // Pair is an aggregate
{
    int m_x {};
    int m_y {};

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };        // now constexpr
    std::cout << p.greater() << '\n'; // p.greater() evaluates at runtime or compile-time

    constexpr int g { p.greater() };  // p.greater() must evaluate at compile-time
    std::cout << g << '\n';

    return 0;
}

image

由于 Pair 是聚合类型,而聚合类型隐式支持 constexpr,至此完成。这确实可行!由于 p 是 constexpr 类型,且 greater() 是 constexpr 成员函数,p.greater() 属于常量表达式,可在仅允许常量表达式的位置使用。

相关内容:
我们在第 13.8 课——结构体聚合初始化中探讨过聚合类型。


常量构造类对象与常量构造函数

现在让我们将Pair改为非聚合类型:

#include <iostream>

class Pair // Pair is no longer an aggregate
{
private:
    int m_x {};
    int m_y {};

public:
    Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };       // compile error: p is not a literal type
    std::cout << p.greater() << '\n';

    constexpr int g { p.greater() };
    std::cout << g << '\n';

    return 0;
}

image

这个示例与前一个几乎完全相同,区别在于Pair不再是聚合类型(因为它包含私有数据成员和构造函数)。

编译此程序时,会出现编译器错误,指出Pair不是“字面量类型”。这是什么情况?

在C++中,字面量类型literal type是指任何可能在常量表达式中创建对象的类型。换言之,除非类型符合字面量类型,否则对象不能成为 constexpr。而我们的非聚合 Pair 不符合条件。

术语说明:
字面量literal字面量类型literal type是两个不同(但相关)的概念。字面量是插入源代码中的 constexpr 值;字面量类型则是可作为 constexpr 值类型的类型。字面量必然具有字面量类型,但具有字面量类型的值或对象未必是字面量。

字面量类型的定义较为复杂,cppreference 提供了概要说明。值得注意的是,字面量类型包括:

  • 标量类型(持有单一值的类型,如基本类型和指针)
  • 引用类型
  • 大多数聚合类型
  • 具有 constexpr 构造函数的类

至此我们明白为何 Pair 不是字面量类型。当类对象实例化时,编译器会调用构造函数初始化对象。而 Pair 类的构造函数并非 constexpr,无法在编译时调用。因此 Pair 对象不能成为 constexpr。

解决方法很简单:只需将构造函数也设为 constexpr:

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {} // now constexpr

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };
    std::cout << p.greater() << '\n';

    constexpr int g { p.greater() };
    std::cout << g << '\n';

    return 0;
}

image

这正如预期那样工作,就像我们聚合版本的Pair一样。

最佳实践:
若需使类在编译时可评估,请将成员函数和构造函数声明为 constexpr。

隐式定义的构造函数若符合条件则默认为 constexpr,显式默认构造函数必须显式声明为 constexpr。

提示:
constexpr 是类接口的组成部分,后期移除会破坏在常量上下文中调用该函数的调用方。


常量表达式Constexpr成员可能需要与非常量表达式non-constexpr/非常量non-const对象配合使用

在上例中,由于常量表达式变量 g 的初始化器必须是常量表达式,显然 p.greater() 必须是常量表达式,因此 p、Pair 构造函数和 greater() 都必须是常量表达式。

然而,若将 p.greater() 替换为常量表达式函数,情况就变得不太明显:

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

constexpr int init()
{
    Pair p { 5, 6 };    // requires constructor to be constexpr when evaluated at compile-time
    return p.greater(); // requires greater() to be constexpr when evaluated at compile-time
}

int main()
{
    constexpr int g { init() }; // init() evaluated in compile-time context
    std::cout << g << '\n';

    return 0;
}

image

请记住,constexpr 函数既可在运行时也可在编译时求值。当 constexpr 函数在编译时求值时,它只能调用能够在编译时求值的函数。对于类类型而言,这意味着只能调用 constexpr 成员函数。

由于 g 是 constexpr,init() 必须在编译时求值。在 init() 函数内部,我们将 p 定义为非 constexpr/非 const(因为可以这样做,而非必须如此)。尽管 p 未被定义为 constexpr,但它仍需在编译时创建,因此需要一个 constexpr Pair 构造函数。同样地,为使 p.greater() 在编译时求值,greater() 必须是 constexpr 成员函数。若 Pair 构造函数或 greater() 其中任一不是 constexpr,编译器将报错。

关键要点:
当 constexpr 函数在编译时上下文中求值时,仅能调用 constexpr 函数。


constexpr 成员函数可为 const 或非 const(C++14)

在 C++11 中,非静态的 constexpr 成员函数默认为 const(构造函数除外)。

然而,从 C++14 开始,constexpr 成员函数不再默认为 const。这意味着若需将 constexpr 函数设为 const 函数,必须显式标记其 const 属性。


constexpr 非 const 成员函数可修改数据成员(可选)

只要隐式对象不是 const,constexpr 非 const 成员函数即可修改类的数据成员。即使函数在编译时评估,此特性依然成立。

以下是一个刻意设计的示例:

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const // constexpr and const
    {
        return (m_x > m_y  ? m_x : m_y);
    }

    constexpr void reset() // constexpr but non-const
    {
        m_x = m_y = 0; // non-const member function can change members
    }

    constexpr const int& getX() const { return m_x; }
};

// This function is constexpr
constexpr Pair zero()
{
    Pair p { 1, 2 }; // p is non-const
    p.reset();       // okay to call non-const member function on non-const object
    return p;
}

int main()
{
    Pair p1 { 3, 4 };
    p1.reset();                     // okay to call non-const member function on non-const object
    std::cout << p1.getX() << '\n'; // prints 0

    Pair p2 { zero() };             // zero() will be evaluated at runtime
    p2.reset();                     // okay to call non-const member function on non-const object
    std::cout << p2.getX() << '\n'; // prints 0

    constexpr Pair p3 { zero() };   // zero() will be evaluated at compile-time
//    p3.reset();                   // Compile error: can't call non-const member function on const object
    std::cout << p3.getX() << '\n'; // prints 0

    return 0;
}

image

在分析这个示例时请记住:

  • 非 const 成员函数可以修改非 const 对象的成员。
  • constexpr 成员函数既可在运行时上下文中调用,也可在编译时上下文中调用。

这两种情况独立运作。

对于 p1,由于其本身是非 const 对象,因此允许调用非 const 成员函数 p1.reset() 来修改 p1。reset() 是否为 constexpr 在此无关紧要,因为当前操作无需编译时评估。

p2 的情况类似。此时 p2 的初始化器是对 zero() 的函数调用。尽管 zero() 是 constexpr 函数,但在运行时上下文中调用时,其行为与普通函数无异。在 zero() 内部,我们实例化非 const 对象 p,调用其非 const 成员函数 p.reset(),然后返回 p。返回的 Pair 对象被用作 p2 的初始化器。此时 zero() 和 reset() 是否为 constexpr 并不重要,因为我们所做的操作都不需要编译时评估。

p3的情况则颇具趣味性。由于p3是constexpr,其初始化器必须为常量表达式。因此对zero()的调用必须在编译时完成。而在编译时上下文中,我们只能调用constexpr函数。在zero()内部,p是非const的(尽管在编译时评估,这仍是允许的)。然而在编译时上下文中,用于创建 p 的构造函数必须是 constexpr。与 p2 情况类似,我们允许对非 const 对象 p 调用非 const 成员函数 p.reset()。但由于处于编译时上下文,reset() 成员函数必须是 constexpr。该函数返回 p,用于初始化 p3。

作者注:
是的,我们使用非const对象初始化了constexpr对象。若此逻辑令你困惑,很可能是尚未完全区分const与constexpr的概念。

constexpr 变量初始化并不强制要求使用 const 值。之所以看似如此,是因为我们通常用常量字面量(const)或其它 constexpr 变量(隐式 const)初始化 constexpr 变量,且 const 与 constexpr 名称相似。

实际要求是:constexpr 变量必须用常量表达式初始化。对于函数(及运算符),constexpr 不等同于 const,constexpr 函数(及运算符)可使用非 const 对象,甚至返回非 const 对象。

关键不在于 const,而在于编译器能否在编译时确定对象的值。对于 constexpr 函数而言,即使返回非 const 对象,编译器仍能实现这一点!


返回 const 引用(或指针)的 Constexpr 函数 可选

通常,您不会看到 constexpr 和 const 紧邻使用,但确实会发生这种情况的一种情况是,当您有一个返回 const 引用(或指向 const 的 (const) 指针)的 constexpr 成员函数时。

在上面的 Pair 类中,getX() 是一个 constexpr 成员函数,它返回一个 const 引用:

constexpr const int& getX() const { return m_x; }

这是很多常量!

constexpr 指示成员函数可以在编译时求值。 const int& 是函数的返回类型。最右边的 const 意味着成员函数本身是 const,因此可以在 const 对象上调用它。

顺便说一句……
返回指向 const 的 const 指针的成员函数可能如下所示:

constexpr const int* const getXPtr() const { return &m_x; }

是不是很漂亮?不?是,好吧。

posted @ 2025-12-31 08:32  游翔  阅读(15)  评论(0)    收藏  举报