C++ Logo

std-proposals

Advanced search

Re: [std-proposals] Function overload set type information loss

From: Tiago Freire <tmiguelf_at_[hidden]>
Date: Wed, 31 Jul 2024 20:43:50 +0000
Quick question, how many years of experience do you have writing code in C++ at a professional level?

________________________________
From: Std-Proposals <std-proposals-bounces_at_[hidden]> on behalf of organicoman via Std-Proposals <std-proposals_at_[hidden]>
Sent: Wednesday, July 31, 2024 10:23:07 PM
To: Sebastian Wittmeier via Std-Proposals <std-proposals_at_[hidden]>
Cc: organicoman <organicoman_at_[hidden]>
Subject: Re: [std-proposals] Function overload set type information loss

This is my proposal(C++17 based) hopefully somebody will rewrite it in a better standard way.
----------------------------------------------------

Operator
   effdecltype(expression)

Other plausible name : typeof (expr)

Intro
    C++ Is a type strong programming language, each object used has a well known type.
For all object instantiated/defined in a program there is a one to one mapping between the name and the type it belongs to.
Except for 3 categories:
1- Arrays
2- Function templates
3- Variable templates

Collapsing type signature into one type id, makes tracing back what was passed/called impossible. And it is an aberration to the strong typing adopted by C++ language.
Let's illustrate that with some examples:
Category 1:

#include <type_traits>
template<typename T>
void foo(T t)
{
    static_assert(std::is_same_v<T, char*>);
}
int main()
{
   char arr[5];
   foo(arr); // #1 assertion passed
   static_assert(std::is_same_v<decltype(arr),
        char[5]>); // #2 assertion passed
}

In the example above, the same variable arr has two different types
#1 char*
#2 char[5]
That's what i mean by type signature collapsing. The standard calls it array pointer decay.

Category 2:

#include<type_traits>
template<typename T>
void foo() { }

int main()
{
  auto foo_1 = &foo<int>;
  auto foo_2 = &foo<double>;
  static_assert
  (
      std::is_same_v
       <
          decltype(foo_1)
        , decltype(foo_2)
>
   ); // #1 assertion passed
}

In this example, we have two functions instances with different type signature, yet their type id is the same.

Category 3:

#include<type_traits>
template<typename T>
int Var = 123;

int main()
{
  auto var_1 = Var<double>;
  auto var_2 = Var<char>;
  static_assert
  (
    std::is_same_v
    <
      decltype(var_1)
    , decltype(var_2)
>
   ); // #1 assertion passed
}

The same as well for variable templates.
Different signature collapsed to same type id.

The standard doesn't explain why it adopted this behavior, except for the case of array pointer decay.
But from the examples above, we can observe a recurring pattern.

There are two different types, one written by the user, and one seen by the compiler.

The type written by the user is the real effective type, but the type seen by the compiler is the apparent type over the rest of the program.

Motivation
    To preserve the one to one mapping between what the user intend and what the program see, and to keep a way to trace back all calls and usage of any type, we need to solve this duality between effective type and apparent type.
Some consequences engendered from this solution, is type introspection and easy type erasure.

Discussion
    While analyzing the behavior of the examples given (category 2, category 3). We extracted the following observations:

1- if the template parameters don't participate in the apparent type of the object, a dissociation between the apparent type and the effective type is created.
Let us call this kind of template parameter an othogonal template paramter

2- All orthogonal template parameters contribute in resolving the instance's value, but don't participate in the type id.

So mathematically speaking:

let:
template<typename...Ts> U;
a templated type where:
U ∈ { category2, category3 }
let:
S(U) = {Ts...} the set of all template arguments of U.
If we prove that there is a sub set A(U) of S(U), where:
A(U) ⊆ S(U) ^ A(U) is not empty.
∀P ∈ A(U) ^ P is an othogonal template paramter.
then:
apparent type(U) is Not the same as effective type(U).

Some examples for illustration:

#include <type_traits>
template<typename T, typename V>
void foo(V v) { }

int main()
{
   auto f_ptr1= &foo<int, double>;
   auto f_ptr2= &foo<char, double>;
   static_assert(std::is_same_v<decltype(f_ptr1), decltype(f_ptr2)>); // assertion passed.
}

In the above example:
S(U) = { T, V }
A(U) = { T }

A(U) is not empty which implys that:
effective type (foo) != apparent type (foo);


Introducing operator effdecltype (or typeof)
    The purpose of this operator is to detect the case described above and record it for the concerned variable, and avoid breaking any code relying on apparent type.

Definitions
    This operator has the following properties.

Let
expr, expr1, expr2 ∈ {category1, category2, category3}

1- If:
effdecltype(expr1) same as effdecltype(expr2)
Then:
decltype(expr1) same as decltype(expr2)

2- casting
decltype(effdecltype(expr)) same as decltype(expr);
Given this property we can write directly
decltype(expr) same as effdecltype(expr)
for convenience.

3- If:
decltype(expr1) same as decltype(expr2)
And
effdecltype(expr1) not as effdecltype(expr2)
Then:
effdecltype(expr1) could be written as
decltype (expr1) <A1(U)>
And:
effdecltype(expr2) could be written as
decltype (expr2) <A2(U)>
Where:
A1(U) and A2(U) are the set of orthogonal template parameters for expr1 and expr2 respectively.

4- change of behavior for auto keyword :
auto var = effdecltype(expr);
auto var2 = expr;

auto here carries the same meta-data as effdecltype(expr), denoting perfect capturing of the effective type.

5- casting:
decltype(expr) var = effdecltype(expr){ expr};

This is an explicit cast of the effective type to an apparent type, and the var object type has no extra meta-data.

6- value comparison:
if:
effdecltype(expr1){ args } == effdecltype(expr2){ args }
then:
decltype(expr1){ args } == decltype(expr2) { args };
And the opposite is also true.
Where:
args are the arguments to the constructor of the expression's type, which are usually the expression itself.

7- fallback to decltype behavior:
If exprX is not one of the 3 categories above.
Then:
effdecltype(exprX) same as decltype(exprX)

In funny summary:
effdecltype is decltype with X-ray vision

Illustration with examples:
(If effdecltype operator is implemented)

#include <type_traits>
template<typename T, typename V>
void foo(V v) { }

template<typename X, typename Y>
auto Var = Y{123};

void
  bar(effdecltype(Var<float, short>) const& a)
{
    static_assert(std::is_same_v<short,
decltype(a)>); // assertion passed

   static_assert
   (
      std::is_same_v
      <
         effdecltype(a)
       , effdecltype(Var<double,short>)
>
    );//passed (must read comment X*)
}

template<typename X>
using meta_double = effdecltype(Var<X,double>);

int main()
{
  auto foo_1 = effdecltype(&foo<int, double>){&foo<int, double>}; //#1

  void(*foo_2)(char) = effdecltype(&foo<double, char>){ &foo<double, char>}; //#2

  auto foo_3 = &foo<long long, char>; // #3

  static_assert
  (
    std::is_same_v
    <
      decltype(foo_3)
    , effdecltype(foo_3)
>
   ); // assertion passed (read comment X*)

  auto var_1 = effdecltype(Var<char, int>){ Var<char, int>};
  int var_2 = Var<double, int>;

  if(var_1 == var_2)
    puts("different effective types, same value"); //printed

  static_assert
  (
    std::is_same_v
    <
        decltype(var_1)
       , effdecltype(var_1)
>
   ); // assertion passed (read comment X*)

   meta_double meta1 = Var<char,double>; // #4
   meta_double<float> meta2 = Var<float,double>; // #5
}

In the example above we illustrated some of the properties of operator effdecltype

In effdecltype's type notation we can write

Case#1: foo_1 is void(*)<int>(double), perfect capture with operator auto.

Case#2: foo_2 is void(*)(char), casting from effective to apparent type.

Case#3: foo_3 is void(*)<long long>(char), perfect capture with operator auto.

Case#4: meta1 is (double)<char>,
First effdecltype was applied to the right hand side of the assignement to deduce the missing orthogonal template parameter of the alias type meta_double, via template argument deduction mechanism, then the value of Var is assigned to meta1

Case#5: meta2 is (double)<float>,
Same as #4 but the othogonal template paramter was explicitly provided, if types mismatch a compiler warning is raised.
The decision to raise a warning instead of an error, is to denote that, what the user writes is what the program should adopt.

Comment X*:
Since the actual type-traits machinery relys on the apparent type to do its job, then when we use effdecltype inside any type-traits meta function the effective type is cast to the apparent type. Remember effdecltype is just decltype with X-ray vision.
Later in the this paper we will show how to fix this situation.

Usage
    After defining what the operator does, we have the opportunity, in this part, to show some usage and implications of such operator.

To use the operator effdecltype effectively, we need to add some tools to our beloved C++ language.

builtin functions:
template<typename U>
constexpr size_t __orthogonal_size__(effdecltype(U{}) = {}) noexpect;

Returns the count of orthogonal template parameters (size of A(U) defined above.)

template<typename U, typename R, size_t I>
constexpr R
__ortho_type_at__<I>(effdecltype(U{}) = {}) noexcept;

Extracts the orthogonal template parameter at index I in the set A(U) defined above.

More builtin functions can be defined, but this is what i have in mind now.

Tips and tricks:
The following is not an exhaustive list of all what we can do with this operator, but just a guid to unleash the user's imagination.

1-store and load types:

#include <vector>
template<typename T>
inline constexpr int Var= 123;

struct S{
    template<typename T>
    using meta_int = effdecltype(Var<T>);
meta_int m_val;
    template<typename T>
    constexpr S() noexcept
      : m_val (Var<T>)
    { }
};

template<typename T>
void ActBasedOnType(int val)
{
  std::cout << typeid(T).name() << "->" << val << std::endl;
}
int main()
{
  std::vector<S> vec;
  vec.push_back(S<int>{} );
  vec.push_back(S<double>{} );
  vec.push_back(S<char>{} );

  for(const auto& elem: vec)
  {
    std::cout << S.m_val << std::endl;
  } // prints: 123\n123\n123\n

  for(const auto& elem: vec)
  {
     auto data = S.s; // uses effdecltype
     ActBasedOnType<__ortho_type_at__<0>(data)>(data);
  }// prints: int->123\ndouble->123\nchar->123\n
}

So what is all this black magic??
Explanation:
Observation: You must keep in mind that the 3 categories we are dealing with are global constexpr variables. And when we assign their values to other variables we can decay it to a reference (case of function pointer) if that variable doesn't modify its value, or we stamp a copy of their values (for variable templates if copy needed).
Our interest is in the orthogonal template parameters not the value.
With this paper addition we are going to exploit that fact deeply.

The struct S above defines a templated effective type alias, captured from the global variable Var<T>.

We define also a function template which prints the type of its template argument.

Inside main we creat a vector of S.
sizeof(S) == sizeof(int) and alignof(S) == alignof(int)
Also, important to notice, is that vector is constexpr vector even we didn't explicity wrote it.
Why? Because all elements of vec are references to a global constexpr variable known at compile time and its size is known at compile time.

We push values of type S, using the templated constructor.
Since we are instantiating values of Var<T> indirectly, the compiler creates an internal table of mapping between orthogonal template parameter T and an index i in vec.

In the first "for loop", the regulare compilation process is performed. m_val is of int type no magic there.

In the second "for loop", the compiler unrolls the loop and instantiates a function out of ActBasedOnType<T> with T fetched from the internal structure created when pushing values into the vector.
So the loop will be replaced by

ActBasedOnType<int>(123);
ActBasedOnType<double>(123);
ActBasedOnType<char>(123);

The compiler has the choice to keep that inner mapping structure for latter use, and it can update the mapping if it sees necessary.
Otherwise it can destroy it if it can prove that there is no necessity to keep it.

All the magic happens in the unrolling of the loop.
Because all information are available at compile time. (Remember the green observation above.)
Unrolling the loop is one technique , other techniques could be stamping a switch statment, or implementing a visitation method.... compilers implementers are free to amaze us.

FIN.


Oh man it's tiresome on a cellphone..🥵


Received on 2024-07-31 20:43:56