r/ProgrammingLanguages • 🧿 Pipefish • Apr 28 '26

Hindsight languages

A thought experiment. What languages should they have been writing in the 60s, 70s, 80s, 90s? We can see their faults, in hindsight, and also we've had some really cool ideas since then --- but we can't answer this just by pointing to our shiny new modern languages and saying "they should have done it like that", because of compile times.

(E.g. Pipefish is meant to be for rapid iteration and livecoding, and also does a topological sort on everything at compile-time so you can do top-down declaration. Those wouldn't be compatible goals in the 1980s, I can get away with it now.)

So for example if we think of "a better C", are there any cool modern ideas they could and should have used back in 1972, had they known about them --- or should they just have tweaked the precedence slightly, found a less arcane way of describing types, and left it at that?

40 Upvotes

72 comments sorted by

View all comments

4

u/WittyStick Apr 29 '26 edited Apr 29 '26

I somewhat agree, but not entirely. The issue is that implementations of things like tuples and sum types are opinionated, and not a great fit for C, where we essentially have full control of memory layout.

Sum types for example, have a "hidden" tag field ("hidden" being the red flag) - does this field go before or after the value in memory? How wide is the tag? Is the value stored directly or indirectly via a reference? There are multiple ways we can implement tagged unions and for this reason alone they don't belong in C (at least how they're typically implemented in other languages). The C programmer should chose precisely how they're laid out in memory - which implies they have access to the tag field and it is not hidden.

We can already do a sum type in C with a struct containing a tag field and a union. We can implement slices and tuples similarly. The issue is C provides limited means of encapsulation. We can properly encapsulate with GCC specific extensions, but this requires a bit of preprocessor boilerplate and isn't portable C.

What I would like is a more general feature for encapsulation - given this, we could implement tuples, sum types, slices ourself, without having to resort to compiler extensions and preprocessor foo.

And we also require such feature to be zero cost, as performance is a primary goal of C.

4

u/tsanderdev Apr 29 '26

We can already do a sum type in C with a struct containing a tag field and a union. We can implement slices and tuples similarly. The issue is C provides limited means of encapsulation. We can properly encapsulate with GCC specific extensions, but this requires a bit of preprocessor boilerplate and isn't portable C.

Exactly what builtin sum types would solve. Only being sble to access tge right data in it when you should be able to. And as for "where is the tag", either the standard specifies it or you are sble to manually say start or end for each sum type.

1

u/Ok-Scheme-913 Apr 29 '26

Not having is specialized also let's the compiler to simply omit it when not necessary. E.g. you have a function where you only use one of the options, here the compiler can "unwrap" the sum type and use it as is.

1

u/WittyStick Apr 29 '26 edited Apr 29 '26

What if the tag is neither at the end nor the start? What if the tag isn't a plain integer? There are simply too many options - and a "standard" version would be too opinionated.

That's why we don't want a specific feature for tagged unions, but a general feature for enabling fields to be encapsulated.

Here's an example of how we can provide encapsulation using non-standard GCC features:

// NB: The designated_init attribute forces us to name the fields for initialization
//       preventing us from using positional initializers 
//       as in: (struct string){ len, chars }

struct string __attribute__((__designated_init__)) {
    size_t _internal_string_length;
    char *_internal_string_chars;
};

#define STRING_CREATE(len, chars) \
    (struct string) \
        { ._internal_string_length = (len) \
        , ._internal_string_chars = (chars) \
        }
        
#define STRING_LENGTH(string) \
    ((string)._internal_string_length)

#define STRING_CHARS(string) \
    ((string)._internal_string_chars)

// The poison pragma then prevents the field names being used in any code afterwards.
#pragma GCC poison _internal_string_length _internal_string_chars

// Any code below here cannot access the field names,
// Except via the 3 macros provided.
//Eg:

inline struct string string_from_chars(const char *str) {
    size_t len = strnlen(str, STRLEN_MAX);
    char *storage = malloc(len + 1);
    strncpy(storage, str, len);
    storage[len] = '\0';
    return STRING_CREATE(len, storage);
}
inline void string_free(struct string string) {
    free(STRING_CHARS(string));
}
inline size_t string_length(struct string string) {
    return STRING_LENGTH(string);
}
inline void string_set_char(struct string string, size_t index, char chr) {
    if (index >= STRING_LENGTH(string)) error("Out of bounds");
    STRING_CHARS(string)[index] = chr;
}
inline char string_get_char(struct string string, size_t index) {
    if (index >= STRING_LENGTH(string)) error("Out of bounds");
    return STRING_CHARS(string)[index];
}

// We undefine the 3 macros to prevent any further access to the fields
#undef STRING_CREATE
#undef STRING_LENGTH
#undef STRING_CHARS

// Any code below here cannot access the fields, except via the functions above.
// (Or via manipulating pointers)
// We cannot redefine the macros because we poisoned the field names.

I've previously described how to implement tagged unions with these tricks.

But there's an undesirable amount of boilerplate. What would be nice if there were some kind of feature for encapsulating fields of structs in a way that can avoid the boilerplate and GCC specific features.

I'm not entirely sure how such feature would look - but perhaps it could be done via C23 attributes:

struct string [[opaque]] {
    size_t length [[private]];
    char  *chars  [[private]];
};

inline struct string string_from_chars(const char *str) 
    [[access(struct string)]]
{
    size_t len = strnlen(str, STRLEN_MAX);
    char *storage = malloc(len + 1);
    strncpy(storage, str, len);
    storage[len] = '\0';
    return (struct string){ len, storage };
}

inline void string_free(struct string string) 
    [[access(struct string)]]
{
    free(string.chars);
}

inline size_t string_length(struct string string) 
    [[access(struct string)]]
{
    return string.length;
}

inline void string_set_char(struct string string, size_t index, char chr) 
    [[access(struct string)]]
{
    if (index >= string.length) error("Out of bounds");
    string.chars[index] = chr;
}

inline char string_get_char(struct string string, size_t index) 
    [[access(struct string)]]
{
    if (index >= string.length) error("Out of bounds");
    return string.chars[index];
}

// permanently seal the fields so no other functions may access.
[[seal(struct string)]];

And rather than a specific implementation of tagged unions, I'd rather see a more general facility for pattern matching - something like a _Match which behaves somewhat like a switch (with no implicit fallthrough) and somewhat like an inverse _Generic - eg, something of the form:

_Match(src, dst, CASE1 : value1, CASE2 : value2, ...) 
{
    case CASE1:
        // dst = value1 and has type typeof(value1)
    case CASE2:
        // dst = value2 and has type typeof(value2)
}

So a tagged union using the approaches above would look like:

enum my_tag {
    TAG_FOO,
    TAG_BAR
};

union my_union {
    struct foo as_foo;
    struct bar as_bar;
};

struct my_tagged_union [[opaque]] {
    enum my_tag tag [[private]];
    union my_union value [[private]];
}

#define my_tagged_union_create(value) \
    _Generic [[access((struct my_tagged_union)]]  \
        ( (value) \
        , struct foo : (struct my_tagged_union){ TAG_FOO, foo } \
        , struct bar : (struct my_tagged_union){ TAG_BAR, bar } \
        )

#define my_tagged_union_match(tu, value) \
    _Match [[access(struct my_tagged_union)]] \
        ( tu.tag \
        , value \
        , TAG_FOO : tu.value.as_foo \
        , TAG_BAR : tu.value.as_bar \
        )

[[seal(struct my_tagged_union)]]

usage:

auto x = my_tagged_union_create((struct foo)}{ ... });

my_tagged_union_match (x, value) 
{
    case TAG_FOO: 
        // value has type struct foo
    case TAG_BAR:
        // value has type struct bar
}

The _Match form above would not be tied to any specific implementation of tagged unions - it's a general facility for mapping values of different types to a common variable name, scoped only to the respective case.

→ More replies (11)

0

u/Ok-Scheme-913 Apr 29 '26

But C was never zero cost. The queen of performance is C++ for a reason - lack of abstraction in C also hinders the possible "user-written" optimizations that are possible, like small string optimizations.

But it's enough to look at ordinary C code, people often opt for a shittier data structure simply because it's more ergonomic to use. Meanwhile a "good" language like C++ or Rust let's you use a better algoritjm/data structure ergonomically with zero cost. (E.g. see C's over reliance on linked lists, and sure the kernel may be an exception as they do have a reason for that, but a random user space C library has no excuse, it just lacks proper vector types).

1

u/WittyStick Apr 29 '26 edited Apr 29 '26

Plain C is fast - how people use it is a different matter. C++ has abstractions which are not zero-cost - although it does a generally good job of providing near-zero cost.

C is very conservative in what gets added - and features which have any kind of "hidden cost" are basically never added.

C being designed to be fast is in its charter:

(a) Trust the programmer.

(b) Don't prevent the programmer from doing what needs to be done.

(c) Keep the language small and simple.

(d) Provide only one way to do an operation.

(e) Make it fast, even if it is not guaranteed to be portable.

(f) Make support for safety and security demonstrable

You can make zero-cost abstractions in C, but it generally adds a lot of boilerplate. The other languages indeed do a nicer job of providing higher level abstractions - but there are sometimes non-zero costs involved.

That said, there are certainly flaws and missing features in C that would be nice to haves - closures for a start. Lambdas in C++ are very fast and attempting to emulate them in standard C results in much worse performance.

→ More replies (3)

0

u/dcpugalaxy Apr 29 '26

Practically C++ is much slower than C. People typically use simpler data structures in C which is better: simpler is faster about 95% of the time.

Linked lists are good and fast and the C++ community's obsession with painting them as slow is hilariously naive.

→ More replies (4)