Same entity, different type?

Tags// , ,

Is(/are) there any scenario(s) where a name, which refers to an entity, actually refers to a different types (whilst still referring to the same entity) depending on context and/or scope? Or, to put it in another way, is(/are) there any scenario(s) where the type of a named entity changes depending on context and/or scope?

where, as a hint, an entity is, as per {basic}/3:

[An entity is] a value, object, reference, function, enumerator, type, class member, bit-field, template, template specialization, namespace, or parameter pack.

Note that we’re not looking for cases where a given name (or, formally, identifier) can refer to different entities due to e.g. overload resolution; it shall refer to the same entity.

Same entity, different type

The answer is the enumerator of an enumeration (we will refer to enumeration as its short name enum, henceforth).

As governed by {dcl.enum}/5 (N4659; March 2017 post-Kona working draft/C++17 DIS) [extract, emphasis mine]:

Following the closing brace of an enum-specifier, each enumerator has the type of its enumeration. If the underlying type is fixed, the type of each enumerator prior to the closing brace is the underlying type […]. If the underlying type is not fixed, the type of each enumerator prior to the closing brace is determined as follows:

  • […]

the type of an enumerator changes depending on whether its referred to from within the definition of the enum which defines it, or from outside of it.

Inside the definition of the enum (no matter if it is a scoped enum or not), the type of an enumerator of the enum (ignoring some details in {dcl.enum}/5.1 and {dcl.enum}/5.3 for enums where the underlying type is not fixed) is that of the underlying type, whereas outside of the definition of the enum, the type of the same enumerator is that of the enum itself.

We may show it in practice with an example, where we start by defining a trait that wraps a type identity check (std::is_same) whilst forwarding a constexpr value:

// util.h
#pragma once
#include <type_traits>

// Forward a constexpr value whilst statically asserting
// type identity between the two type template parameters.
template <typename T, typename U, int VALUE>
struct is_same_and_value {
    static_assert(std::is_same_v<T, U>, "");
    static constexpr int value = VALUE;
};

template <typename T, typename U, int VALUE>
constexpr int is_same_and_value_v =
    is_same_and_value<T, U, VALUE>::value;

and thereafter make use of this trait to check the type identity of an enumerator from within an enum definition whilst simultaneously initializing another enumerator with a constexpr value.

#include <type_traits>
#include "util.h"

enum class Foo: int;
constexpr Foo f = static_cast<Foo>(0);

enum class Foo: int {
  A,
  // A (and other enumerators) are of the same type as the
  // underlying type ('int') within the scope of the enum definition.
  B = is_same_and_value_v<decltype(A), int, 1>,
  // Whereas the 'f', however, naturally has the type (const) 'Foo'.
  C = is_same_and_value_v<decltype(f), const Foo, 5>,
};

// Outside of the definition, the type of each enumerator, however,
// is no longer that of the underlying type, but that of the enum.
static_assert(std::is_same_v<decltype(Foo::A), Foo>, "");
static_assert(!std::is_same_v<decltype(Foo::B), int>, "");

int main() {}

It is this property of enumerators that allows initializing an enumerator with a constant expression that makes use of the value of another enumerator whose definition precedes it, without having to rely on implicit conversion between the enume type and its underlying type (for which special rules applies, which we briefly visit below).

Implicit and explicit conversions from enum types to their underlying types

N23471 (Strongly Typed Enums (revision 3)) introduced strongly typed enums, addressing the problems of weak typing, implicit conversions (that followed from the former), implementation-defined underlying types and weak scoping of the unscoped enums inherited from C.

In the context of this post, we may note that the value of an enumerator of or object of an unscoped enum type can be implicitly converted to an integer by integral conversion and integral promotion, as governed by {conv.integral}/1 and {conv.prom}/4 ({conv.prom}/3 for unscoped enum types whose underlying type is not fixed), respectively [ emphasis mine]:

[conv.integral]/1 A prvalue of an integer type can be converted to a prvalue of another integer type. A prvalue of an unscoped enumeration type can be converted to a prvalue of an integer type.

[conv.prom]/4 A prvalue of an unscoped enumeration type whose underlying type is fixed ([dcl.enum]) can be converted to a prvalue of its underlying type. Moreover, if integral promotion can be applied to its underlying type, a prvalue of an unscoped enumeration type whose underlying type is fixed can also be converted to a prvalue of the promoted underlying type.

This means the following program, where Foo is an unscoped enum with fixed underlying type, is well-formed:

#include <iostream>

enum Foo: int;
constexpr Foo f = static_cast<Foo>(0);

enum Foo: int {
  A = f,  // Implicit conversion from 'Foo' to 'int'.
  B
};

int main() {
    switch(f) {
        case Foo::A: std::cout << "Foo::A\n"; break;
        default: std::cout << "Not Foo::A;"; break;
    }  // Foo::A
}

Whereas the similar example where Foo has been changed into a scoped enum (still with a fixed underlying type) is ill-formed:

enum class Foo: int;
constexpr Foo f = static_cast<Foo>(0);

enum class Foo: int {
  A = f,  // Error: could not convert 'f' from 'const Foo' to 'int'.
  B
};

This was part of the design intent of scoped enums from N2347, particularly addressing the problems stemming from allowing implicit conversion from an enumerator of or an object of an unscoped enum type to an integer. With scoped enums, we are now required to apply explicit type conversion when converting from scoped enums or enumerators of scoped enums to integers, e.g. particularly when converting to their underlying integer types.

enum class Foo: int;
constexpr Foo f = static_cast<Foo>(0);

enum class Foo: int {
  A = static_cast<int>(f), // OK.
  B
};

Upcoming enum improvements

We will wrap up by mentioning the P1099R5 (Using Enum) proposal, which was accepted in time to make it into the C++20 standard, and which allows to associate enums with a using directive [extract]:

Abstract Class enums are restricted namespaces. Let’s extend the using declaration to them.

[…] Consider an enum class:

enum class rgba_color_channel { red, green, blue, alpha };

Currently, a switch using this enum looks as follows:

  switch (channel) {
    case rgba_color_channel::red:   return "red";
    case rgba_color_channel::green: return "green";
    case rgba_color_channel::blue:  return "blue";
    case rgba_color_channel::alpha: return "alpha";
  }
}

[…] The above example would then be rewritten as

std::string_view to_string(rgba_color_channel channel) {
  switch (my_channel) {
    using enum rgba_color_channel;
    case red:   return "red";
    case green: return "green";
    case blue:  return "blue";
    case alpha: return "alpha";
  }
}

[…] This change is meant to allow the introduction of class members that are enumerators into non-class scope. […]

struct S {
    enum E { x };
    enum class EC { y };
    using EC::y;
};

void f() {
    using S::x; // OK
    x; // resolves to S::E::x;
    using S::y; // OK
    y; // resolves to S::EC::y;
}

[…]

This paper has been approved by CWG in Cologne 2019 for C++20 after being approved by EWG in Kona 2019 (ship vehicle C++20).

Same entity, different declared type

Another example that comes close to fulfill the criteria above, but arguably does not reach all the way, is the case where an array object may have a declared type of an array of unknown bound, at one point, and a declared type as an array of known bound. [basic.types]/6 states [ emphasis mine]:

A class type (such as “class X”) might be incomplete at one point in a translation unit and complete later on; the type “class X” is the same type at both points. The declared type of an array object might be an array of incomplete class type and therefore incomplete; if the class type is completed later on in the translation unit, the array type becomes complete; the array type at those two points is the same type. The declared type of an array object might be an array of unknown bound and therefore be incomplete at one point in a translation unit and complete later on; the array types at those two points (“array of unknown bound of =T=” and “array of =N T=”) are different types. […] [ Example:

extern int arr[];  // the type of arr is incomplete
int arr[10];       // now the type of arr is complete

— end example ]

The extern int arr[]; declaration above describes an array of an incomplete type, whereas the actual object (which is the entity here), denoted by the name of the variable (ref. [basic]/4 & [basic]/6: a variable by itself is not an entity) has the single complete type int[42], as specified in its definition int arr[42];. Thus, for the array case it is the declared type that differs and not the type of the entity (although this area is arguably a bit gray in the standard).

In other words; even if the declared type of a variable whose name denotes an object may differ depending on scope, the type of the actual entity, i.e., the object, will always be that of the complete type. The essential difference here is between that of a variable (whose name denotes an object) and an actual object (which is the entity); otherwise we could arguably argue that (legal) aliasing actually changes the type of the underlying object (the entity).

Finally, recall that in the example of enumerators above, they are explicitly named as entities (in their own right), as are objects (but not variables).


  1. N2347 was integrated into the C++0X draft, which eventually became the C++11 standard. ↩︎

comments powered by Disqus