Type-namespace and symbols set control

Author : Jean-Baptiste Vallon Hoarau

Abstract

This paper describe several new language features to control the association of types with functions. The aim is to provide a set of coherent mechanisms that facilitate customizations points, interface adaptation, support ADL control and potentially provide a replacement for ADL.

This paper is only a rough draft and is intentionally kept very short (relatively to the breadth of the problems it try to address). A more rigorous paper will be written should there be any interests.

1. Type-namespace

A type-namespace is a namespace accepting one or more types as parameters. The idea of type-namespace draws inspiration from abstract data types (or concept maps, Rust traits, type-classes, etc.) and traits class template.

The anonymous type-namespace associated with a type is part of the interface of a type.
Type-namespace can be templated and specialized using semantics similar to those described in [P1292R0] 1.

1.1 Anonymous type-namespace

The anonymous type-namespace of a single type is considered the interface of a type.
I propose the following rule to replace the current ADL rules in the future :

In order for a function F to be implicitly included in the set of available functions for unqualified look-up, F must be available in the associated anonymous type-namespaces of the types of the arguments.

This restrict considerably the set of available symbols and result in code that is safer and easier to reason about (and possibly faster to compile). Here is a simple example :

namespace nms {
	
	// definition 
	template <class T>
	class my_type {
		// ...
	};
	
	// associated anonymous type-namespace
	template <class T>
	namespace <my_type<T>> {
		template <class A>
		void moo(A&& a){}
	}
	
	template <class T>
	void moo(T&& t){}
}

namespace bar {
	struct inner_type{};
	
	template <class T>	
	void moo(T&& t){}
}

int main(){
	nms::my_type<bar::inner_type> cow;
	moo(cow);
}

Under the current ADL rules, the unqualified call moo(cow) is ambiguous, as ADL looks both into the entire nms and bar namespaces.
Under the new rules, this call would result in an unambiguous call to
nms::<my_type<bar::inner_type>>::moo, and in a compile-time error if no such function exists.

An anonymous type-namespace can be extended, unless declared final.

1.2 Virtual type-namespace

A virtual type-namespace is a namespace that can be overridden using a mechanism similar to template specialization. A specialized namespace can be itself overridden unless specified final. The namespace become an alias for the namespace overriding it - except in the overriding namespace itself, who can access the symbols of the primary namespace, to reuse default definitions.

Here is an example of a customization point for a data visualization API :

template <class T>
virtual namespace plot_traits {

	auto get_plot_point(T& t, std::size_t index)
	{
		return std::make_pair( t[index].x, t[index].y );
	}
	
	auto get_num_points(T& t){
		return t.size();	
	}
}

template <class T>
final namespace complex_plot : override plot_traits< std::vector<std::complex<T>> > {
	
	auto get_plot_point(std::vector<std::complex<T>>& v, std::size_t index){
		return std::make_pair( v[index].real(), v[index].imag() );
	}
	
	// OK as plot_traits<...> inside a plot_traits override always refer to the primary namespace
	using plot_traits< std::vector<std::complex<T>> >::get_num_points;
}

// error : complex_plot was declared final
namespace double_complex_plot : override complex_plot<double> {
	auto get_plot_point(std::vector<std::complex<double>>& v, std::size_t index){
		return "Breaking your code";
	}
}

The functions inside the virtual namespace can then be accessed as follow :

template <class T>
void make_plot(T&& t)
{
	std::vector<std::pair<float, float>> plot_data;
	auto size = plot_traits<T>::get_num_points(t);
	plot_data.reserve(size);
	
	for (int k = 0; k < size; ++k){
		plot_data.push_back( plot_traits<T>::get_plot_point(t) );	
	}
}

Const/ref/pointers removal is applied to the type of a type-namespace.
By default, no restriction is applied to the definition of an overridden type-namespace. The rationale for this is that we should keep language features orthogonal, and concepts can already be used for checking if a definition is well-formed.

1.3 Implication for the Unified Call Syntax proposal

The unified call syntax proposal 2 faced some opposition, notably as x.f(y) meaning f(x, y) can break SFINAE code. Should there be a will to pursue Unified Call Syntax, the introduction of anonymous type-namespace could help : if the UFCS adopt the new ADL rules exposed above, the call to x.f(y) would only ever look into the associated anonymous type-namespace of x and y, therefore not breaking any code.

2. Symbols set control

In order to assert or observe the location of a function, reuse the symbols of a namespace in a modular way, or provide simple customization points, we often need to control the current symbols set.
I propose the introduction of the following new mechanisms to this effect.

2.1 Exclusion : “not using”

The not using expression can be used to exclude a namespace or function from the set of available symbols. This mechanism alone is sufficient to replace customizations points objects as described in [N4381] 3 and [P1895] 4.

namespace std::ranges {
		
	template <class T>
	auto begin(T& t) -> decltype(t.begin())
	{	
		return t.begin();
	}
	
	template <class T>
	auto begin(T& t)
	{
		not using ::std::ranges::begin;   // exclude this begin from look-up
		not using namespace ::std;  	  // also works : exclude the entire std namespace
		return begin(t);                  // call any begin except this one
	}
}

2.2 Self-referencing : “this”

It can become complex to keep track of namespaces referencing inside of deep namespaces hierarchy.
I propose to introduce the this token to refer to the current namespace.
The above example can then be rewritten :

namespace std::ranges {

	template <class T>
	auto begin(T& t)
	{
		not using this::begin;      // exclude the local begin
		not using namespace this; // also works : exclude std::ranges
		return begin(t); 		
	}
}

2.3 Taming the ADL dragon : “namespace(T)”

The namespace(T) refer to the set of symbols associated with the type T, i.e. the symbols that will be considered by ADL for T. This can be used, among other things, to fully disable ADL :

namespace nms {
	template <class T>
	void moo(T&& t){}
	
	template <class T>
	void some_function(T&& t){
		not using namespace(T);
		moo(t); // always nms::moo
		namespace(T)::moo(t); // calling moo somewhere in namespace(T)
	}
}

Note : currently ADL can be disabled by wrapping a function call in parentheses (e.g. (moo)(t)), but this is not a very satisfying notation, for operators in particular.

2.4 Prioritizing : “override”

An using declaration or directive by itself gives no priority to the imported symbols : they are considered equal to the ones already present in the set.
I propose to introduce the override token to be added after an using declaration or directive to consider the imported symbols as to prioritized in any case, that is : if a callable overload can be found in the set of prioritized symbols, it will be used regardless of whether or not a better overload can be found in another set.

This can be useful in combination of type-namespace. Additionally, this can also be of help in the design of Unified Functions Call Syntax, as one of the design problem encountered is how members vs non-members should be prioritized with respect to the calling notation.
Giving programmers a tool for prioritizing symbol allow anyone who might not be happy with the priority rules for UFCS to tweak them :

template <class T>
void some_function(T&& t){
	using namespace(T)::T override;   // prioritize member functions
	t.blah(); 	// always a member function if it exists
	using namespace<T> override;      // prioritize non member functions (anonymous namespace)
	t.blah(); 	// always a free function if it exists
}

To prevent errors arising from over-aggressive shadowing, the priority level of the symbols of the current namespace shall remains always the same (we usually want our code to call the functions defined next to it).

2.5 A broader brush : variadic notation

To give further control over how symbols are included and excluded, I propose a variadic notation similar to the notation of using operators declaration in classes.

namespace A {
	void moo(){}
	void blah(){}
}

namespace B {
	using namespace A;
}

namespace C {
	
	using A::...; // import every symbols of A
	// not the same thing as "using namespace A" as
	// the functions from A can now be found by unqualified look-up from any scope importing C
	
	template <class T>
	void moo(T&& t){
		not using namespace(...); // exclude every symbols from every other namespace
		// the 'using A::..." declaration is invalidated
		using namespace(T);       // include the symbols associated with T
		blah(t);                  // blah must be in namespace(T)
	}
}

int main(){
	using namespace C;
	moo(); // ok : A::moo has been imported in C and is available for unqualified look-up
	not using namespace C;
	using namespace B;
	moo(); // error : A::moo has been imported in B, but is only available from within B
		   // or by qualified call
}

To recapitulate :

References


  1. “Customization points functions”, Matt Calabrese, 2018 ↩︎

  2. “Unified Call Syntax”, Stroustrup & Sutter, 2015 ↩︎

  3. Suggested design for customization points, Eric Niebler, 2015 ↩︎

  4. tag_invoke : A general pattern for supporting customisable functions, Baker, Niebler, Shoop, 2019 ↩︎