C++ Logo

std-proposals

Advanced search

Re: [std-proposals] Generic code, not the API you are looking for

From: Jason McKesson <jmckesson_at_[hidden]>
Date: Mon, 29 Aug 2022 13:11:39 -0400
On Mon, Aug 29, 2022 at 11:32 AM Arthur O'Dwyer via Std-Proposals
<std-proposals_at_[hidden]> wrote:
>
> On Sat, Aug 27, 2022 at 11:21 AM Михаил Найденов via Std-Proposals <std-proposals_at_[hidden]> wrote:
>>
>> Hello again,
>> I ended up writing a small proposal on this topic as I am quite bothered with the issue.
>> Attached is the draft
>
>
> Hi Mikhail,
> I looked at your paper. I think it needs to use the words "concept maps" somewhere in the paper: that's basically what you're proposing as a solution, but the paper doesn't show any evidence that you've dug into the previous proposals and why they failed.
>
> Then, at a higher level, you have not convinced me that there's any problem here at all! Your original, "just programming," version of your function looks like this (quote):
>
> void silly(string& s)
> {
> using char_type = string::char_type;
> // ...
> if(s.starts_with("hello"))
> s.clear();
> // ...
> }
>
> I don't think that code is ordinary "just programming" at all! `std::string::char_type` (1) does not exist, and (2) even if it did exist, I assume it would be an unnecessarily verbose way of spelling "char". So let's eliminate that line. Then, we're left with nicely generic code, that looks just like classic Stepanov STL code. This function template (or "algorithm") can be compiled and works perfectly for any concrete type that implements the `starts_with` method (e.g. `string`, `string_view`) and the `clear` method (e.g. `string`, `vector`, `deque`, `map`, `set`...)
>
> template<class T>
> void silly(T& t) {
> if (t.starts_with("hello")) t.clear();
> }
>
> Of course in the actual STL there's only the one type (`string`) that supports both of those operations. But that's fine, because that's the only STL type that can actually be used with this template. If we intended to use it with both `string` and `string_view`, we could generalize it a little further like this:
>
> template<class T>
> void silly(T& t) {
> if (t.starts_with("hello")) t = T();
> }
>
> But nobody writes templates in a vacuum: there's always some actual business purpose behind writing the template. Usually it's to avoid code duplication: we have two or more known types that satisfy the same interface, and so we can write one ("statically polymorphic") version of our algorithm using a template like `silly`. The interface and the template algorithm generally will evolve cooperatively. For example, the STL is designed to facilitate the writing of algorithms that work for any kind of STL container, so, all STL containers support the same vocabulary of methods: `insert`, `erase`, `begin`, `end`, `T::iterator`, `T::value_type`, and so on.
>
> It seems to me that you're identifying the problem as "I can't write a template that works for all possible types, because (statically-)polymorphic code implies an interface, and not every possible type will implement the interface I require." But that's not a real-world problem, because in the real world we don't write code to work with every possible type. And then, you have an XY problem: you try to get around this artificial problem by crufting up your code, turning all your methods into free functions and adding a lot of `::`s, and then complain that this makes the code uglier. Yes — but that's the "Y" problem! Crufting up the code is ugly, but it isn't necessary to solve your "X" problem; and in fact, it's not even sufficient. In order to use polymorphism (either static polymorphism with templates, or classical runtime polymorphism with inheritance), your types must conform to some kind of interface. That's both unavoidable and beneficial-to-the-reader.

I think the last paragraph is kind of missing the forest for the
trees. The proposal isn't good at saying what it's trying to say; it
focuses on the wrong issues and not really the things that matter. But
the point is not without merit.

Yes, we don't write code to work with "every possible type." But the
whole point of generic programming is to write code that works with an
*extensible* subsets of types, defined by an interface, with the user
being able to write new types that conform to that interface. This is
how the iterator system is designed to work.

The goal of the "silly" example is to allow users to create their own
string types which conform to the string interface. And given the
proliferation of string types among C++ users, that's hardly an
unreasonable Y to want an X solution for (though personally,
`string_view` solves basically 90% of string-related interactions).

The problem being cited here is the syntax needed to do this.

Type traits and customization point objects both exist to solve
different aspects of the same problem: how do I *force* some type I
don't control to conform to an interface against which it was not
written?

The classic example being pointers-as-iterators. For class types,
having member type aliases like `value_type` makes sense. But pointers
are not classes; they cannot have member type aliases. Type traits are
used to allow types to have type aliases associated with the type when
the type cannot be changed or fundamentally cannot have member type
aliases.

Customization point objects work the same way. By using a free
function, you can give a type an interface that matches what the
customization point is looking for. And if you define it in the proper
namespace, ADL will find it, so the customization point object can use
ADL-based lookup to find it and avoid other overloads that happen to
have the same name.

But both of these require a lot of boilerplate code. Those wanting to
make a type conform to the interface have to specialize type traits
and/or implement these customization point functions. And looking up
everything you need to do is not obvious and not well-defined by code.
Those wanting to write code that uses types conforming to the
interface have to write a lot of syntax to call customization point
objects and access type traits.

So essentially we have 3 problems:

1: Writing the interface.
2: Writing the code needed to make a type conform to the interface,
particularly when the type cannot be altered)
3: Writing the code which consumes types that define the interface.

Concepts cover #1, but the way C++20 concepts is defined makes them
decidedly non-useful for solving #2 and #3. Indeed, we deliberately
made concepts lite so that it *couldn't* help in those things.

That being said, C++20 concepts can still be useful in providing a
solution. Consider the idea of an "interface concept" (this syntax is
purely theoretical):

```
template<typename T>
interface concept iface
{
  typename some_type;
  bool simple(this T const &, int i) { return i == 50; };
};
```

`iface` is a type concept and can be used wherever type concepts can
be used. But exactly what they test is a bit different from a regular
concept. What it says is that `T`s must provide a `some_type` type
alias. And that `T`s *can* provide a `simple` function (we'll get to
how that works presently). If `T` does not provide such, then the
default implementation defined in the interface concept must be used.

What's interesting is how the writer of some type can expose such things.

If a type has a member alias named `some_type`, then it satisfies that
requirement. If a type has a member function named `simple` that can
be called with an `int` (by using explicit object parameter syntax
here, we indicate that we're looking for a member function. If you
don't use explicit object parameter syntax, then you're looking for a
non-member function) and returns a `bool`, then it satisfies the
requirement.

However, a type can explicitly be declared to satisfy an interface.
This is done by "specializing" the interface concept:

```
interface concept iface<MyType>
{
  typename some_type = int;
  bool simple(MyType &const t, int i) {return t.foo() == i;}
};
```

These are explicit interfaces. If you don't declare a matching
explicit interface from `iface`, and `iface` doesn't have a matching
default one, then this specialization is ill-formed. You don't have to
override any default interfaces, but you do have to override
un-defaulted ones.

This `simple` definition overrides the default one when it is being
called on `MyType`.

Where I veer off from the proposal under discussion is how code *uses*
these interfaces. His suggestion effectively wants you to be able to
inject members into the namespace of the type. This is impractical for
a host of reasons, not the least being the way C++20 concepts work.

So instead, any access to the interface needs to specify that it is
accessing the type/object through `iface`, not through its natural
interface. So that might be something like `iface<A>::simple(t, 50);`
This also allows the compiler to check to see if `iface` actually has
the interface you're accessing (providing a degree of concept
checking).

I haven't thought this idea through to any significant degree, but I
like the focus on specifying an interface and specializing that
interface for a particular type, rather than the OP's focus on what
the code using that interface has to look like.

Received on 2022-08-29 17:12:19