Skip to content

The C3 Blog

A look at modules (in general + in the context of C3)

Originally from: https://c3.handmade.network/blog/p/8650-a_look_at_modules_in_general__in_the_context_of_c3

Despite being a general concept, modules are often very different from language to language. One major reason for this is that overall language semantics puts many constraints on how modules may work. However, despite these constraints there is a lot of specific design work required.

I'm going to look at the modules in general and also talk a little about how C3 modules work.

An initial observation

When making a module system one first have to decide whether a module is a separate concept or not. Because if the language has the idea of static variables and functions attached to a type there is actually already a sort of module system present.

Here is a short snippet written in the C2 language to illustrate this:

// File bar.c2
module bar;
// Plain function
func int get_one() {
    return 1;
}  

// File foo.c2
module foo;
import bar;
type Bar struct {
  int x;
}  
// Static function
func int Bar.get_one() {
    return 1;
}

func void test() {
    int a = Bar.get_one();
    int b = bar.get_one();
}

The type here acts as namespace in itself. If we extend the type with static variable we can similarly emulate namespaced global variables.

Most languages with methods on their types gladly accept this ambiguity, but one can draw the conclusion that modules are not needed and only structs are necessary. This is the approach taken by Zig. The downside is that it also leads to counter-intuitive things such as "a file is a struct" and having to explicitly arrange sub-modules in a hierarchy.

The other way to resolve the ambiguity is to have type methods, but abolish static methods and globals. This is the approach of C3. The downside is that some methods that are naturally static, such as Foo.new_instance() or constants Foo.MAX_VALUE can't be expressed.

We can also note that Java, while having "packages" use classes as the primary namespacing mechanism for free functions and constants, which is a bit more relaxed than Zig's approach, since the hierarchy is external.

Sub-modules and paths

Flat vs hierarchal

The module namespace can be flat with a single module name or hierarchal, where modules have sub-modules. While flat modules are nice to work with and easy to implement, there is much more contention for unique names. This can mean that module names may need to have longer names to require uniqueness, e.g. mylib_io for the flat module and mylib::io for the hierarchal. But hierarchal modules in general have an even worse problem with length: e.g. std.debug.print("Hello, world!\n", .{}); (with apologies to Zig).

Aliasing and import

The obvious solutions to long names are aliasing and namespace imports. Here is again a C2 example:

import networking as net; // Aliasing
import filesystem local; // Namespace import


// Equivalent:
doSomething(); // Namespace import
filesystem.doSomething();

// Equivalent:
net.connect(); // Aliased
networking.connect();

The downside of aliasing is that aliases may differ between authors and implementations. So while someone might alias networking to net, someone else uses nw. This together with the difficulty of naming aliases makes it a less attractive solution. Full namespace import avoids naming issues, but makes it much less clear what are local functions and what is implemented elsewhere.

C3 path shortening

C3 has a hierarchal module system but employs path shortening. This is basically that the first part of a module path may be elided: std::net::sockets::new_from_url(url) can be used as sockets::new_from_url(url) as long as it is not ambiguous.

Requiring at least the sub-module name in the path is a design decision to avoid the readability problems mentioned with namespace imports. In the example "new_from_url(url)" on its own lacks the context that the "sockets::" prefix gives.

Surveying other languages it's clear that usually contain sufficient context in their names. For this reason they are exempt from the prefix requirement in C3.

Note how something similar happens in Java in practice: java.math.BigInteger is the import, you then use BigInteger, but call static "functions" namespaced: BigInteger prime = BigInteger.probablePrime(128, rnd);

In the Java case this comes from import java.math.BigInteger being an actual namespace import, but then the classes themselves provide a second layer or namespacing.

Visibility

The other major component to modules is visibility between modules. Note that nothing is saying that explicit imports are necessary: with full paths the correct types, functions and variables may be found anyway.

With "import" statements the most common scheme is this:

  • Modules not imported: no visibility.
  • Module imported: public declarations are visible.

Hierarchal visibility

As a complement to the above in hierarchal module systems, a module may see non-public declarations in sub modules and/or parent modules.

The desire to have this feature arise from wanting to separate the visible "api layer" module and the internal "implementation layer" modules that which contains implementation details that may change over time.

The downside of this method for modules to peek into other modules is the need to build this into the hierarchy.

"Friend" visibility

As an alternative to the above hierarchal visibility above is to declare "friend" modules that may access the module. This has fewer constraints than trying to fit modules neatly into the right sort of hierarchy just to get the correct visibility between modules.

There is still the drawback that in order to "friend" another module, the module needs to know of that other module.

Becoming a "friend"

Often the concept of visibility is conflated with some idea of "internal safety": "I make this private to make it safe from other modules". This is trying to interpolate the metaphor too far. Visibility and access modifiers are there to help the user of the types to use / override functionality in the correct way. "Public" communicates that this function is made for general consumption, "private" means internal consumption and it not being part of the surface API of the functionality.

However, if one knows what one is doing then circumventing these protections can be useful. For example:

  • There may be a bug that can be circumvented by calling private methods.
  • One may want to exploit the particular functionality of a specific version of a library.
  • One may want to modify behaviour for some other reason that the author did not foresee.

Often languages have convoluted ways of circumventing visibility in these cases, e.g. calling functions using reflection in Java, just because the need does arise.

The obvious way is then for a module to be able to declare itself the friend of a module. A C3 example:

module test;
fn void fn_private() @private {}

module foo;
import test @public; // Override visibility

fn void main()
{
    // This is not an error due to the "@private" import.
    test::fn_private();
}

We can note that C3 has public by default. It is possible to set a different default:

module test2 @private;

fn void fn_private() {}
fn void fn_public() @public {} // Explicitly needs @public!

Visibility levels

To talk about visibility at all we need at least two levels to differentiate between. Usually these are public and private, where public means visible outside of the module and private being visible only inside of the module.

In fact, we could stop here because this will in most cases be all we need. For this reason there is a possibility to not encode this in a keyword, but in the name itself: Go's "uppercase means public" and Dart's "leading underscore means private" (note: I considered the latter for C3).

Between "private" and "public"

If we want hierarchal visibility, then we need another level above private but below public, indicating that something is available to other modules (below or above) in the hierarchy.

Similarly, for the "friend" module visibility we need a visibility level for this behaviour. As an example Rust has pub(in path) and pub(crate) (although note that both of those are somewhat constrained).

Below "private"

If modules may span multiple source files, there is the possibility of another visibility level, where visibility is restricted to the file with the declaration. This is C's static, Swift's fileprivate and C3's @local (Note: while C3 could have used static for globals and functions, it's a poor name for type visibility. This is why @local was chosen instead).

This is not exhaustive: depending on language features more visibility levels might be possible. For C3 with import @private, having "public", "private" and "local" seems to cover most use cases.

Imports

While imports usually is a good way to determine dependencies, this is not guaranteed. As an example: while most Java programmers may think of Java's import as importing classes, all it actually does is to fold namespaces.

The point here is that while import may roughly correspond to the dependency graph, it's not guaranteed to exactly do so. This means that imports is usually simply a way to limit the pollution of the current namespace.

This is very valuable though, in fact this is a variant of the public / private division: importing is picking a set of modules that can be accessed (= is public to the current module).

Narrow imports

In the Java world, wildcard imports (e.g. import java.util.*) is by tradition considered bad. Instead Java source files often contain a litany of single class imports. This is such a problem that most IDEs offer to both hide the list of imports and manage it for you.

In the Java case the tangible benefit claimed is that if you do something like this:

import java.util.*;
import java.sql.*;

You have problem if you try to use Date since it's now unambiguous.

Having written a lot of Java code that works with the DB I can confidently say that the problem here is not the imports, but the reuse of Date in both Java packages. If the java.sql class had a reasonable name like SqlDate this import would not have been a problem AND there would be no confusion when trying to use a java.util.Date and java.sql.Date in the same code, which happens quite often.

So the fact that the above is touted as a reason just shows how weak the arguments are for narrow imports in Java.

HOWEVER if a language uses import to actually pull in dependencies, then narrow is likely better, but it's important to note that this isn't necessarily the case. It's not true in Java, nor is it true in C3.

No imports?

One might think that dumping all modules in the current namespace would be unworkable, but if we already use the full path to types and functions, there are no ambiguities. Even C3 abbreviated paths work fine in general.

The downside is that now things like code completion is going to match EVERYTHING in all modules, which just makes for a much worse experience. This also affects things like error messages. The imports help the compiler (and an IDE) to make better guesses and in general just be more friendly.

A middle ground

In C3 imports are implicitly wildcard, so import std::io will also import sub modules to std::io. It's also possible to have more than one import in a single row, e.g. import std::io, std::math;. To me this seems like a reasonable compromise.

More controversially, C3 modules will implicitly import parent and child modules. So std::io::socket could implicitly import std::io, std and the child module std::io::socket::channel. I am not sure of this feature and it might go away. That said, because there is no sibling module import (e.g. std::io does not implicitly import std::math), the namespace pollution is still fairly low.

Dependency resolution

If the import does not resolve the actual dependency graph, then all code must be at least parsed and analysed. For the C3 compiler this is not a problem, since lexing, parsing and semantic analysis is a fraction of the total compilation time. However, it's desirable to output only the part of the code that is in use.

Exports

We have one more problem: just because a function is public doesn't mean it should be exported in a library.

We can illustrate this with a simple example: let's say we want to build a simple web scraper which creates a list of all the image URLs on a web page. To do so we use a module which handles http + https and writes a thin layer on top with a single function that takes a string and returns a list or strings with the URLs. In other word, we only have a single function that we want to export.

But if we create a static library with this functionality and naively export the public functions we will get the not just get our single function, but the public functions of the http module as well... plus public functions of anything the http module uses!

While the linker might strip unused code when creating an executable, even in this case we will still generate code that is not used.

Explicit exports

The first necessary feature is to be able to mark functions and globals as being exported. Note that being exported is orthogonal to public / private. Public and private is about source level visibility, and exports is about library and linker visibility.

Because exported functions are usually public, some languages conflate public and export, making export simply a variant of "public". (In C3 the @export makes a function or global exported, it has no effect on visibility between modules).

Entry points => dependency graph

With export we're now able to make a real dependency graph. For a regular executable the main function can be considered the entry point, otherwise we use functions marked export to trace dependencies.

Summary

  • We have looked how static methods and member overlap with module namespaced functions and globals. This means namespacing can be done with modules, static methods and member or a combination thereof. C3 uses modules only.
  • Modules may be flat or hierarchal. C3 uses a hierarchal module namespace.
  • Various methods may be used to reduce repetitive module prefixing. Aliasing namespace inlining are common. C3 uses path shortening.
  • The simplest visibility semantics only has public and private.
  • Accessing "private" functions is useful, and there are various solutions.
  • One method is adding a special visibility level to let a parent or child module access private functions.
  • Another method is defining what other modules as "friends" to access private functions as if they were public.
  • C3 allows a module to import private functions of other modules.
  • C3 has three visibility levels: @public @private and @local. "local" means it is local to the current module section.
  • Imports can be narrow or wide. C3 prefers wildcard imports. Narrow imports is mostly useful when imports directly can infer the dependency graph.
  • Exports need to be different from "all of the public functions".
  • C3 uses @export to mark declarations to export.

If you want to try out C3, you can test it here: https://learn-c3.org.

Comments


Comment by Christoffer Lernö

Despite being a general concept, modules are often very different from language to language. One major reason for this is that overall language semantics puts many constraints on how modules may work. However, despite these constraints there is a lot of specific design work required.

I'm going to look at the modules in general and also talk a little about how C3 modules work.

An initial observation

When making a module system one first have to decide whether a module is a separate concept or not. Because if the language has the idea of static variables and functions attached to a type there is actually already a sort of module system present.

Here is a short snippet written in the C2 language to illustrate this:

// File bar.c2
module bar;
// Plain function
func int get_one() {
    return 1;
}  

// File foo.c2
module foo;
import bar;
type Bar struct {
  int x;
}  
// Static function
func int Bar.get_one() {
    return 1;
}

func void test() {
    int a = Bar.get_one();
    int b = bar.get_one();
}

The type here acts as namespace in itself. If we extend the type with static variable we can similarly emulate namespaced global variables.

Most languages with methods on their types gladly accept this ambiguity, but one can draw the conclusion that modules are not needed and only structs are necessary. This is the approach taken by Zig. The downside is that it also leads to counter-intuitive things such as "a file is a struct" and having to explicitly arrange sub-modules in a hierarchy.

The other way to resolve the ambiguity is to have type methods, but abolish static methods and globals. This is the approach of C3. The downside is that some methods that are naturally static, such as Foo.new_instance() or constants Foo.MAX_VALUE can't be expressed.

We can also note that Java, while having "packages" use classes as the primary namespacing mechanism for free functions and constants, which is a bit more relaxed than Zig's approach, since the hierarchy is external.

Sub-modules and paths

Flat vs hierarchal

The module namespace can be flat with a single module name or hierarchal, where modules have sub-modules. While flat modules are nice to work with and easy to implement, there is much more contention for unique names. This can mean that module names may need to have longer names to require uniqueness, e.g. mylib_io for the flat module and mylib::io for the hierarchal. But hierarchal modules in general have an even worse problem with length: e.g. std.debug.print("Hello, world!\n", .{}); (with apologies to Zig).

Aliasing and import

The obvious solutions to long names are aliasing and namespace imports. Here is again a C2 example:

import networking as net; // Aliasing
import filesystem local; // Namespace import


// Equivalent:
doSomething(); // Namespace import
filesystem.doSomething();

// Equivalent:
net.connect(); // Aliased
networking.connect();

The downside of aliasing is that aliases may differ between authors and implementations. So while someone might alias networking to net, someone else uses nw. This together with the difficulty of naming aliases makes it a less attractive solution. Full namespace import avoids naming issues, but makes it much less clear what are local functions and what is implemented elsewhere.

C3 path shortening

C3 has a hierarchal module system but employs path shortening. This is basically that the first part of a module path may be elided: std::net::sockets::new_from_url(url) can be used as sockets::new_from_url(url) as long as it is not ambiguous.

Requiring at least the sub-module name in the path is a design decision to avoid the readability problems mentioned with namespace imports. In the example "new_from_url(url)" on its own lacks the context that the "sockets::" prefix gives.

Surveying other languages it's clear that usually contain sufficient context in their names. For this reason they are exempt from the prefix requirement in C3.

Note how something similar happens in Java in practice: java.math.BigInteger is the import, you then use BigInteger, but call static "functions" namespaced: BigInteger prime = BigInteger.probablePrime(128, rnd);

In the Java case this comes from import java.math.BigInteger being an actual namespace import, but then the classes themselves provide a second layer or namespacing.

Visibility

The other major component to modules is visibility between modules. Note that nothing is saying that explicit imports are necessary: with full paths the correct types, functions and variables may be found anyway.

With "import" statements the most common scheme is this:

  • Modules not imported: no visibility.
  • Module imported: public declarations are visible.

Hierarchal visibility

As a complement to the above in hierarchal module systems, a module may see non-public declarations in sub modules and/or parent modules.

The desire to have this feature arise from wanting to separate the visible "api layer" module and the internal "implementation layer" modules that which contains implementation details that may change over time.

The downside of this method for modules to peek into other modules is the need to build this into the hierarchy.

"Friend" visibility

As an alternative to the above hierarchal visibility above is to declare "friend" modules that may access the module. This has fewer constraints than trying to fit modules neatly into the right sort of hierarchy just to get the correct visibility between modules.

There is still the drawback that in order to "friend" another module, the module needs to know of that other module.

Becoming a "friend"

Often the concept of visibility is conflated with some idea of "internal safety": "I make this private to make it safe from other modules". This is trying to interpolate the metaphor too far. Visibility and access modifiers are there to help the user of the types to use / override functionality in the correct way. "Public" communicates that this function is made for general consumption, "private" means internal consumption and it not being part of the surface API of the functionality.

However, if one knows what one is doing then circumventing these protections can be useful. For example:

  • There may be a bug that can be circumvented by calling private methods.
  • One may want to exploit the particular functionality of a specific version of a library.
  • One may want to modify behaviour for some other reason that the author did not foresee.

Often languages have convoluted ways of circumventing visibility in these cases, e.g. calling functions using reflection in Java, just because the need does arise.

The obvious way is then for a module to be able to declare itself the friend of a module. A C3 example:

module test;
fn void fn_private() @private {}

module foo;
import test @public; // Override visibility

fn void main()
{
    // This is not an error due to the "@private" import.
    test::fn_private();
}

We can note that C3 has public by default. It is possible to set a different default:

module test2 @private;

fn void fn_private() {}
fn void fn_public() @public {} // Explicitly needs @public!

Visibility levels

To talk about visibility at all we need at least two levels to differentiate between. Usually these are public and private, where public means visible outside of the module and private being visible only inside of the module.

In fact, we could stop here because this will in most cases be all we need. For this reason there is a possibility to not encode this in a keyword, but in the name itself: Go's "uppercase means public" and Dart's "leading underscore means private" (note: I considered the latter for C3).

Between "private" and "public"

If we want hierarchal visibility, then we need another level above private but below public, indicating that something is available to other modules (below or above) in the hierarchy.

Similarly, for the "friend" module visibility we need a visibility level for this behaviour. As an example Rust has pub(in path) and pub(crate) (although note that both of those are somewhat constrained).

Below "private"

If modules may span multiple source files, there is the possibility of another visibility level, where visibility is restricted to the file with the declaration. This is C's static, Swift's fileprivate and C3's @local (Note: while C3 could have used static for globals and functions, it's a poor name for type visibility. This is why @local was chosen instead).

This is not exhaustive: depending on language features more visibility levels might be possible. For C3 with import @private, having "public", "private" and "local" seems to cover most use cases.

Imports

While imports usually is a good way to determine dependencies, this is not guaranteed. As an example: while most Java programmers may think of Java's import as importing classes, all it actually does is to fold namespaces.

The point here is that while import may roughly correspond to the dependency graph, it's not guaranteed to exactly do so. This means that imports is usually simply a way to limit the pollution of the current namespace.

This is very valuable though, in fact this is a variant of the public / private division: importing is picking a set of modules that can be accessed (= is public to the current module).

Narrow imports

In the Java world, wildcard imports (e.g. import java.util.*) is by tradition considered bad. Instead Java source files often contain a litany of single class imports. This is such a problem that most IDEs offer to both hide the list of imports and manage it for you.

In the Java case the tangible benefit claimed is that if you do something like this:

import java.util.*;
import java.sql.*;

You have problem if you try to use Date since it's now unambiguous.

Having written a lot of Java code that works with the DB I can confidently say that the problem here is not the imports, but the reuse of Date in both Java packages. If the java.sql class had a reasonable name like SqlDate this import would not have been a problem AND there would be no confusion when trying to use a java.util.Date and java.sql.Date in the same code, which happens quite often.

So the fact that the above is touted as a reason just shows how weak the arguments are for narrow imports in Java.

HOWEVER if a language uses import to actually pull in dependencies, then narrow is likely better, but it's important to note that this isn't necessarily the case. It's not true in Java, nor is it true in C3.

No imports?

One might think that dumping all modules in the current namespace would be unworkable, but if we already use the full path to types and functions, there are no ambiguities. Even C3 abbreviated paths work fine in general.

The downside is that now things like code completion is going to match EVERYTHING in all modules, which just makes for a much worse experience. This also affects things like error messages. The imports help the compiler (and an IDE) to make better guesses and in general just be more friendly.

A middle ground

In C3 imports are implicitly wildcard, so import std::io will also import sub modules to std::io. It's also possible to have more than one import in a single row, e.g. import std::io, std::math;. To me this seems like a reasonable compromise.

More controversially, C3 modules will implicitly import parent and child modules. So std::io::socket could implicitly import std::io, std and the child module std::io::socket::channel. I am not sure of this feature and it might go away. That said, because there is no sibling module import (e.g. std::io does not implicitly import std::math), the namespace pollution is still fairly low.

Dependency resolution

If the import does not resolve the actual dependency graph, then all code must be at least parsed and analysed. For the C3 compiler this is not a problem, since lexing, parsing and semantic analysis is a fraction of the total compilation time. However, it's desirable to output only the part of the code that is in use.

Exports

We have one more problem: just because a function is public doesn't mean it should be exported in a library.

We can illustrate this with a simple example: let's say we want to build a simple web scraper which creates a list of all the image URLs on a web page. To do so we use a module which handles http + https and writes a thin layer on top with a single function that takes a string and returns a list or strings with the URLs. In other word, we only have a single function that we want to export.

But if we create a static library with this functionality and naively export the public functions we will get the not just get our single function, but the public functions of the http module as well... plus public functions of anything the http module uses!

While the linker might strip unused code when creating an executable, even in this case we will still generate code that is not used.

Explicit exports

The first necessary feature is to be able to mark functions and globals as being exported. Note that being exported is orthogonal to public / private. Public and private is about source level visibility, and exports is about library and linker visibility.

Because exported functions are usually public, some languages conflate public and export, making export simply a variant of "public". (In C3 the @export makes a function or global exported, it has no effect on visibility between modules).

Entry points => dependency graph

With export we're now able to make a real dependency graph. For a regular executable the main function can be considered the entry point, otherwise we use functions marked export to trace dependencies.

Summary

  • We have looked how static methods and member overlap with module namespaced functions and globals. This means namespacing can be done with modules, static methods and member or a combination thereof. C3 uses modules only.
  • Modules may be flat or hierarchal. C3 uses a hierarchal module namespace.
  • Various methods may be used to reduce repetitive module prefixing. Aliasing namespace inlining are common. C3 uses path shortening.
  • The simplest visibility semantics only has public and private.
  • Accessing "private" functions is useful, and there are various solutions.
  • One method is adding a special visibility level to let a parent or child module access private functions.
  • Another method is defining what other modules as "friends" to access private functions as if they were public.
  • C3 allows a module to import private functions of other modules.
  • C3 has three visibility levels: @public @private and @local. "local" means it is local to the current module section.
  • Imports can be narrow or wide. C3 prefers wildcard imports. Narrow imports is mostly useful when imports directly can infer the dependency graph.
  • Exports need to be different from "all of the public functions".
  • C3 uses @export to mark declarations to export.

If you want to try out C3, you can test it here: https://learn-c3.org.

Considering user-defined numerical types

Originally from: https://c3.handmade.network/blog/p/8635-considering_user-defined_numerical_types

Recently, I did some work on the math libraries for C3. This involved working on vector, matrix and complex types. In the process I added some conveniences to the built in (simd) vector types. One result of this was that rather than having a Vector2 and Vector3 user defined type, I would simply add methods to float[<2>] and float[<3>] (+ double versions). This works especially well since + - / * are all defined on vectors.

In other words, even without operator overloading this works:

float[<2>] a = get_a();
float[<2>] b = get_b();
return a + b;

Seeing as a complex number being nothing other than a vector of two elements, it seemed interesting to implement the complex type that way, and get much arithmetics for free.

Just making a complex type a typedef of float[<2>] has problems though. Any method defined on the complex type would be defined on float[<2>]!

define Complex = float[<2>]; // Type alias
// Define multiply
fn Complex Complex.mul(Complex a, Complex b) 
{
    return {
        a[0] * b[0] - a[1] * b[1], 
        a[1] * b[0] + b[1] * a[0] 
    };
}
...
float[<2>] f = get_f();
f = f.mul(f); // Accidentally get the Complex version!

Distinct types

Now C3 has the concept of "distinct" types. That is, a type which is in practice identical to some type, but has a different name and will not be implicitly cast into the other. For example, I can write define Id = distinct int and have the compiler complain if an int rather than an Id is used.

This is similar to the C trick of wrapping a type in a struct, but without the inconvenience of that method.

This solves our problem form from before:

define Complex = distinct float[<2>]; // Distinct type
fn Complex Complex.mul(Complex a, Complex b) 
{
    return {
        a[0] * b[0] - a[1] * b[1], 
        a[1] * b[0] + b[1] * a[0] 
    };
}
float[<2>] f = get_f();
Complex c = get_c();
c = c.mul(c); // Works.
f = f.mul(f); // Compile time error!

At first glance this looks promising. Unfortunately the advantages of distinct types becomes a disadvantage: the distinct type retains the functions of the original type. For the c + d case this is what we want, but c * d is not what we expect:

Complex c = { 1, 3 };
Complex d = { 2, 7 };
e = c.mul(d); // Correct! e is { -19, 13 }
e = c * d; // e is { 2, 21 }

While we can try to remember to use the right thing, it's far from ideal. Especially if this is baked into the standard library: you can't have a type that mostly behaves incorrectly for regular operators!

Possible solutions

Since we're able to change the language semantics to try to "fix" this while still using "distinct", there are a few obvious solutions:

  1. Being able to "override" an operator for a distinct type. In this case we would override * and / and leave the other operators. This would be a limited form of overloading.
  2. Being able to "turn off" operators. So in this case we turn off * and / forcing the programmer to use methods, like mul and div instead.
  3. Always require to explicitly inherit operators and methods.

These could work, but we need to recognize the added complexity needed for these solutions. And on top of that, some functionality can't quite be described this way, such as conversion of floats to complex numbers.

Operator overloading with structs

The common solution in C++ would be a struct with operator overloading to get + - * /. C3 doesn't have operator overloading for numbers, but maybe we could add it?

However, operator overloading is not sufficient to get us conversion from floats to complex numbers. For that we need user-defined conversion operators, which interacts with the type system in various ways. This leaves the whole problem with custom constructor and custom conversions: is float -> Complex a conversion function on float or a construction function on Complex? All of this interacts in subtle ways with other implicit conversions.

Built-in types

Another possibility is of course to make the types built-in. After all this is how C does complex types. But then the problem is how to limit it: ok, complex types built-in but then what about quaternions? Matrices? Matrices with complex numbers? Matrices with quaternions?!

Drawing a line here means some types have better support than others, and trying to go beyond (simd) vectors, it's hard to figure out where that line should be drawn.

Worth it?

Ultimately each feature needs to be balanced against utility. Are the benefits sufficiently big to motivate the cost. Comparing what is already in C3 against what would be necessary to add, it seems that the cost would be fairly high.

Even if vectors work for complex numbers, matrices are more likely to require operator overloading with structs which is a bigger feature than overriding operators on distinct types. This means that the idea fixing so that Complex can be a vector is a feature with very limited use.

General overloading and user-defined conversion functions can be applied to a wider set of types, but has a much higher cost with the primary use restricted to numeric types and string handling.

So even if it's more useful, it's also costs a whole lot more in terms of language complexity, making it ultimately a net negative for the language as a whole.

So unless I have come up with some other solution, user-defined numeric types will have to stick with methods and explicit conversions.

Comments


Comment by Christoffer Lernö

Recently, I did some work on the math libraries for C3. This involved working on vector, matrix and complex types. In the process I added some conveniences to the built in (simd) vector types. One result of this was that rather than having a Vector2 and Vector3 user defined type, I would simply add methods to float[<2>] and float[<3>] (+ double versions). This works especially well since + - / * are all defined on vectors.

In other words, even without operator overloading this works:

float[<2>] a = get_a();
float[<2>] b = get_b();
return a + b;

Seeing as a complex number being nothing other than a vector of two elements, it seemed interesting to implement the complex type that way, and get much arithmetics for free.

Just making a complex type a typedef of float[<2>] has problems though. Any method defined on the complex type would be defined on float[<2>]!

define Complex = float[<2>]; // Type alias
// Define multiply
fn Complex Complex.mul(Complex a, Complex b) 
{
    return {
        a[0] * b[0] - a[1] * b[1], 
        a[1] * b[0] + b[1] * a[0] 
    };
}
...
float[<2>] f = get_f();
f = f.mul(f); // Accidentally get the Complex version!

Distinct types

Now C3 has the concept of "distinct" types. That is, a type which is in practice identical to some type, but has a different name and will not be implicitly cast into the other. For example, I can write define Id = distinct int and have the compiler complain if an int rather than an Id is used.

This is similar to the C trick of wrapping a type in a struct, but without the inconvenience of that method.

This solves our problem form from before:

define Complex = distinct float[<2>]; // Distinct type
fn Complex Complex.mul(Complex a, Complex b) 
{
    return {
        a[0] * b[0] - a[1] * b[1], 
        a[1] * b[0] + b[1] * a[0] 
    };
}
float[<2>] f = get_f();
Complex c = get_c();
c = c.mul(c); // Works.
f = f.mul(f); // Compile time error!

At first glance this looks promising. Unfortunately the advantages of distinct types becomes a disadvantage: the distinct type retains the functions of the original type. For the c + d case this is what we want, but c * d is not what we expect:

Complex c = { 1, 3 };
Complex d = { 2, 7 };
e = c.mul(d); // Correct! e is { -19, 13 }
e = c * d; // e is { 2, 21 }

While we can try to remember to use the right thing, it's far from ideal. Especially if this is baked into the standard library: you can't have a type that mostly behaves incorrectly for regular operators!

Possible solutions

Since we're able to change the language semantics to try to "fix" this while still using "distinct", there are a few obvious solutions:

  1. Being able to "override" an operator for a distinct type. In this case we would override * and / and leave the other operators. This would be a limited form of overloading.
  2. Being able to "turn off" operators. So in this case we turn off * and / forcing the programmer to use methods, like mul and div instead.
  3. Always require to explicitly inherit operators and methods.

These could work, but we need to recognize the added complexity needed for these solutions. And on top of that, some functionality can't quite be described this way, such as conversion of floats to complex numbers.

Operator overloading with structs

The common solution in C++ would be a struct with operator overloading to get + - * /. C3 doesn't have operator overloading for numbers, but maybe we could add it?

However, operator overloading is not sufficient to get us conversion from floats to complex numbers. For that we need user-defined conversion operators, which interacts with the type system in various ways. This leaves the whole problem with custom constructor and custom conversions: is float -> Complex a conversion function on float or a construction function on Complex? All of this interacts in subtle ways with other implicit conversions.

Built-in types

Another possibility is of course to make the types built-in. After all this is how C does complex types. But then the problem is how to limit it: ok, complex types built-in but then what about quaternions? Matrices? Matrices with complex numbers? Matrices with quaternions?!

Drawing a line here means some types have better support than others, and trying to go beyond (simd) vectors, it's hard to figure out where that line should be drawn.

Worth it?

Ultimately each feature needs to be balanced against utility. Are the benefits sufficiently big to motivate the cost. Comparing what is already in C3 against what would be necessary to add, it seems that the cost would be fairly high.

Even if vectors work for complex numbers, matrices are more likely to require operator overloading with structs which is a bigger feature than overriding operators on distinct types. This means that the idea fixing so that Complex can be a vector is a feature with very limited use.

General overloading and user-defined conversion functions can be applied to a wider set of types, but has a much higher cost with the primary use restricted to numeric types and string handling.

So even if it's more useful, it's also costs a whole lot more in terms of language complexity, making it ultimately a net negative for the language as a whole.

So unless I have come up with some other solution, user-defined numeric types will have to stick with methods and explicit conversions.

Handling parsing and semantic errors in a compiler

Originally from: https://c3.handmade.network/blog/p/8632-handling_parsing_and_semantic_errors_in_a_compiler

There was recently a question on r/ProgrammingLanguages about error handling strategies in a compiler.

The more correct errors a compiler can produce, the better for a language where compile times are long. On the other hand false positives are not helping anyone.

In my case, the language compiles fast enough, so my focus has been to avoid false positives. I use the following rules:

  1. Lexing errors: these are handed over to the parser creating parser errors.
  2. Parsing errors: skip forward until there is some token it is possible to safely sync on.
  3. Parser sync: some tokens will always be the start of a top level statement in my language: struct, import, module. Those are safe to use. For some other token types indentation can help: for example in C3 fn is a good sync token if it appears at zero indentation, but if it's found further in, it's likely part of a function type declaration: define Foo = fn void();. Only sync on tokens you are really sure of.
  4. No semantic analysis for code that doesn't parse: code that doesn't parse are very unlikely to semantically analyse. Pessimistic parser sync means lots of valid code may get skipped, making semantic analysis fail even though the code might pass.
  5. Use poisoning during semantic analysis. I saw this first described by Walter Bright, the creator of the D-Language. It is simple and incredibly effective in avoiding incorrect error reporting during semantic analysis. The algorithm is simply this: if an AST-node has an error, report it and mark it as poisoned. Then proceed to mark the parent of this AST-node poisoned as well, stopping any further analysis of the node (but without reporting any further errors).

I don't do anything particularly clever in regards to error reporting, but I found that these rules are sufficient to give very robust and correct error handling.

Comments


Comment by Christoffer Lernö

There was recently a question on r/ProgrammingLanguages about error handling strategies in a compiler.

The more correct errors a compiler can produce, the better for a language where compile times are long. On the other hand false positives are not helping anyone.

In my case, the language compiles fast enough, so my focus has been to avoid false positives. I use the following rules:

  1. Lexing errors: these are handed over to the parser creating parser errors.
  2. Parsing errors: skip forward until there is some token it is possible to safely sync on.
  3. Parser sync: some tokens will always be the start of a top level statement in my language: struct, import, module. Those are safe to use. For some other token types indentation can help: for example in C3 fn is a good sync token if it appears at zero indentation, but if it's found further in, it's likely part of a function type declaration: define Foo = fn void();. Only sync on tokens you are really sure of.
  4. No semantic analysis for code that doesn't parse: code that doesn't parse are very unlikely to semantically analyse. Pessimistic parser sync means lots of valid code may get skipped, making semantic analysis fail even though the code might pass.
  5. Use poisoning during semantic analysis. I saw this first described by Walter Bright, the creator of the D-Language. It is simple and incredibly effective in avoiding incorrect error reporting during semantic analysis. The algorithm is simply this: if an AST-node has an error, report it and mark it as poisoned. Then proceed to mark the parent of this AST-node poisoned as well, stopping any further analysis of the node (but without reporting any further errors).

I don't do anything particularly clever in regards to error reporting, but I found that these rules are sufficient to give very robust and correct error handling.

Killing off structural casts

Originally from: https://c3.handmade.network/blog/p/8630-killing_off_structural_casts

Structural casts are now gone from C3. This was the ability to do this:

struct Foo { int a; int b; }
struct Bar { int x; int y; }

fn void test(Foo f)
{
    // Actual layout of Foo is the same as Bar
    Bar b = (Bar)f;
    // This also ok:
    int[2] x = (int[2])f;
}

Although I think that in some ways this is a good feature, it is too permissive to be good: it's not always clear that the structural cast is even intended, and yet it suddenly allows a wide range of (explicit) casts. While doing a pointer case like (Bar*)&f would usually raise all sorts of warning flags, one would typically assume a value cast to be fairly safe and intentional. Structural casting breaks that.

The intention was a check that essentially confirms that bitcasting from one type to the other will retain match the internal data. This could then be combined with an @autocast attribute allowing something like this:

fn void foo(@autocast Foo f) { ... }

fn void test()
{
    Bar b = { 1, 2 };
    foo(b); // implicitly foo((Foo)b) due to the @autocast
}

The canonical use for this was when an external API could be used with a structurally equivalent internal type: For example you use a library which takes a Vector2 everywhere, and maybe there is another library in use that has it's Vector2D. And finally there is a Vec2 used internally in the application. With structural casts, these could be used interchangeably as long as they were structurally equivalent.

However, there are other solutions: transparent unions (see the GCC feature), macro forwarding wrappers and user definable conversions.

Then there is the question of use cases: while this vector case is common enough, I can't think of many other uses. (We might also note that when people want operator overloading, it's collections and user defined vector types they will take as examples).

So if the standard library is defining some vector types, or the use of real vector types becomes dominant then the interoperability use case might completely go away.

In any case, this is one more feature that seemed really cool to have, but ended up being less useful than expected.

(It might be somewhat useful to have compile time function that determines if two types are structurally identical though, as this allows you to build macros that work for a set of structurally equivalent types, should you ever want to)

Comments


Comment by Christoffer Lernö

Structural casts are now gone from C3. This was the ability to do this:

struct Foo { int a; int b; }
struct Bar { int x; int y; }

fn void test(Foo f)
{
    // Actual layout of Foo is the same as Bar
    Bar b = (Bar)f;
    // This also ok:
    int[2] x = (int[2])f;
}

Although I think that in some ways this is a good feature, it is too permissive to be good: it's not always clear that the structural cast is even intended, and yet it suddenly allows a wide range of (explicit) casts. While doing a pointer case like (Bar*)&f would usually raise all sorts of warning flags, one would typically assume a value cast to be fairly safe and intentional. Structural casting breaks that.

The intention was a check that essentially confirms that bitcasting from one type to the other will retain match the internal data. This could then be combined with an @autocast attribute allowing something like this:

fn void foo(@autocast Foo f) { ... }

fn void test()
{
    Bar b = { 1, 2 };
    foo(b); // implicitly foo((Foo)b) due to the @autocast
}

The canonical use for this was when an external API could be used with a structurally equivalent internal type: For example you use a library which takes a Vector2 everywhere, and maybe there is another library in use that has it's Vector2D. And finally there is a Vec2 used internally in the application. With structural casts, these could be used interchangeably as long as they were structurally equivalent.

However, there are other solutions: transparent unions (see the GCC feature), macro forwarding wrappers and user definable conversions.

Then there is the question of use cases: while this vector case is common enough, I can't think of many other uses. (We might also note that when people want operator overloading, it's collections and user defined vector types they will take as examples).

So if the standard library is defining some vector types, or the use of real vector types becomes dominant then the interoperability use case might completely go away.

In any case, this is one more feature that seemed really cool to have, but ended up being less useful than expected.

(It might be somewhat useful to have compile time function that determines if two types are structurally identical though, as this allows you to build macros that work for a set of structurally equivalent types, should you ever want to)

The downsides of compile time evaluation

Originally from: https://c3.handmade.network/blog/p/8590-the_downsides_of_compile_time_evaluation

Macros and compile time evaluation are popular ways to extend a language. While macros fell out of favour by the time Java was created, they've returned to the mainstream in Nim and Rust. Zig has compile time and JAI has both compile time execution and macros.

At one point in time I was assuming that the more power macros and compile time execution provided the better. I'll try to break down why I don't think so anymore.

Code with meta programming are hard to read

Macros and compile time form a set of meta programming tools, and in general meta programming has very strong downsides in terms of maintaining and refactoring code. To understand code with meta programming you have to first resolve the meta program in your head, and not until you do so you can think about the runtime code. This is exponentially harder than reading normal code.

Bye bye, refactoring tools

It's not just you as a programmer that need to resolve the meta programming – any refactoring tool would need to do the same in order to safely do refactorings – even simple ones as variable name changes.

And if the name is created through some meta code, the refactoring tool would basically need to reprogram your meta program to be correct, which is unreasonably complex. This is why everything from preprocessing macros to reflection code simply won't refactor correctly with tools.

Making it worse: arbitrary type creation

Some languages allow that arbitrary types are created at compile time. Now the IDE can't even know how types look unless it runs the meta code. If the meta code is arbitrarily complex, so will the IDE need to be in order to "understand" the code. While the meta programming evalution might be nicely ordered when running the compiler, a responsive IDE will try to iteratively compile source files. This means the IDE will need to compile more code to get the correct ordering.

Code and meta code living together.

Many languages try to make the code and meta code look very similar. This leads to lots of potential confusion. Is a a compile time variable (and thus may change during compilation, and any expression containing it might be compile time resolved) or is it a real variable?

Here's some code, how easy is it to identify the meta code?

fn performFn(comptime prefix_char: u8, start_value: i32) i32  {
    var result: i32 = start_value;
    comptime var i = 0;
    inline while (i < cmd_fns.len) : (i += 1) {
        if (cmd_fns[i].name[0] == prefix_char) {
            result = cmd_fns[i].func(result);
        }
    } 
    return result;
}

I've tried to make it easier in C3 by not mixing meta and runtime code syntax. This is similar how macros in C are encouraged to be all upper case to avoid confusion:

macro int performFn(char $prefix_char, int start_value)
{
    int result = start_value;
    // Prefix $ all compile time vars and statements
    $for (var $i = 0; $i < CMD_FNS.len, $i++):
        $if (CMD_FNS[$i].name[0] == $prefix_char):
            result = CMD_FNS[$i].func(result);
        $endif;   
    $endfor;   
    return result;
}

The intention with the C3 separate syntax is that the approximate runtime code can be found by removing all rows starting with $:

macro int performFn(char $prefix_char, int start_value)
{
    int result = start_value;


            result = CMD_FNS[$i].func(result);


    return result;
}

Not elegant, but the intention is to maximize readability. In particular, look at the "if/$if" statement. In the top example you can only infer that it is compile time evaluated and folded by looking at i and prefix_char definitions. In the C3 example, the $if itself guarantees the contant folding and will return an error if the boolean expression inside of () isn't compile time folded.

Extending syntax for the win?

A popular use for macros is for extending syntax, but this often goes wrong. Even if you have a language with a macro system that is doing this well, what does it mean? It means that suddenly you can't look at something like foo(x) and be able to make assumptions about it. In C without macros we can make the assumption that neither x nor other local variables will not changed (unless they have been passed by reference to some function prior to this), and the code will resume running after the foo call (except if setjmp/longjmp is used). With C++ we can asume less, since foo may throw an exception, and x might implicitly be passed by reference.

The more powerful the macro system the less we can assume. Maybe it's pulling variables from the calling scope and changing them? Maybe it's returning from the current context? Maybe it's formatting the drive? Who knows. You need to know the exact definition or you can't read the local code and this undermines the idea of most languages.

Because in a typical language you will what "breaks the rules": all the built in statements like if, for and return. Then there is a way to extend the language that follows certain rules: functions and types. This forms the common language understood by a developer to be what "knowing a language is about": you know the syntax and semantics of the built-in statements.

If the language extends its syntax, then every code base becomes a DSL which you have to learn from scratch. This is similar to having to buy into some huge framework in the JS/Java-space, just worse.

The point is that while we're always extending the syntax of the language, doing this through certain limited mechanisms like functions works well, but the more unbounded the extension mechanisms the harder the code will be to read and understand.

When meta programming is needed

In some cases meta programming can make code more readable. If the problem is something like having a pre-calculated list for fast calculations or types defined from a protocol, then code generation can often solve the problem. Languages can improve this by better compiler support for triggering codegen.

In other cases the meta programming can be replaced by running code at startup. Having "static init" like Java static blocks can help for cases when libraries need to do initialization.

If none of those options work, there is always copy-paste.

Summary

So to summarize:

  • Code with meta programming is hard to read (so minimize and support readability).
  • Meta programming is hard to refactor (so adopt a subset that can work with IDEs).
  • Arbitrary type creation is hard for tools (so restrict it to generics).
  • Same syntax is bad (so make meta code distinct).
  • Extending syntax with macros is bad (so don't do it).
  • Codegen and init at runtime can replace some use of compile time.

Macros and compile time can be made extremely powerful, but this power is tempered by the huge drawbacks, good macros are not what you can do with them, but if it manages to balance readability with necessary features.

Comments


Comment by Christoffer Lernö

Macros and compile time evaluation are popular ways to extend a language. While macros fell out of favour by the time Java was created, they've returned to the mainstream in Nim and Rust. Zig has compile time and JAI has both compile time execution and macros.

At one point in time I was assuming that the more power macros and compile time execution provided the better. I'll try to break down why I don't think so anymore.

Code with meta programming are hard to read

Macros and compile time form a set of meta programming tools, and in general meta programming has very strong downsides in terms of maintaining and refactoring code. To understand code with meta programming you have to first resolve the meta program in your head, and not until you do so you can think about the runtime code. This is exponentially harder than reading normal code.

Bye bye, refactoring tools

It's not just you as a programmer that need to resolve the meta programming – any refactoring tool would need to do the same in order to safely do refactorings – even simple ones as variable name changes.

And if the name is created through some meta code, the refactoring tool would basically need to reprogram your meta program to be correct, which is unreasonably complex. This is why everything from preprocessing macros to reflection code simply won't refactor correctly with tools.

Making it worse: arbitrary type creation

Some languages allow that arbitrary types are created at compile time. Now the IDE can't even know how types look unless it runs the meta code. If the meta code is arbitrarily complex, so will the IDE need to be in order to "understand" the code. While the meta programming evalution might be nicely ordered when running the compiler, a responsive IDE will try to iteratively compile source files. This means the IDE will need to compile more code to get the correct ordering.

Code and meta code living together.

Many languages try to make the code and meta code look very similar. This leads to lots of potential confusion. Is a a compile time variable (and thus may change during compilation, and any expression containing it might be compile time resolved) or is it a real variable?

Here's some code, how easy is it to identify the meta code?

fn performFn(comptime prefix_char: u8, start_value: i32) i32  {
    var result: i32 = start_value;
    comptime var i = 0;
    inline while (i < cmd_fns.len) : (i += 1) {
        if (cmd_fns[i].name[0] == prefix_char) {
            result = cmd_fns[i].func(result);
        }
    } 
    return result;
}

I've tried to make it easier in C3 by not mixing meta and runtime code syntax. This is similar how macros in C are encouraged to be all upper case to avoid confusion:

macro int performFn(char $prefix_char, int start_value)
{
    int result = start_value;
    // Prefix $ all compile time vars and statements
    $for (var $i = 0; $i < CMD_FNS.len, $i++):
        $if (CMD_FNS[$i].name[0] == $prefix_char):
            result = CMD_FNS[$i].func(result);
        $endif;   
    $endfor;   
    return result;
}

The intention with the C3 separate syntax is that the approximate runtime code can be found by removing all rows starting with $:

macro int performFn(char $prefix_char, int start_value)
{
    int result = start_value;


            result = CMD_FNS[$i].func(result);


    return result;
}

Not elegant, but the intention is to maximize readability. In particular, look at the "if/$if" statement. In the top example you can only infer that it is compile time evaluated and folded by looking at i and prefix_char definitions. In the C3 example, the $if itself guarantees the contant folding and will return an error if the boolean expression inside of () isn't compile time folded.

Extending syntax for the win?

A popular use for macros is for extending syntax, but this often goes wrong. Even if you have a language with a macro system that is doing this well, what does it mean? It means that suddenly you can't look at something like foo(x) and be able to make assumptions about it. In C without macros we can make the assumption that neither x nor other local variables will not changed (unless they have been passed by reference to some function prior to this), and the code will resume running after the foo call (except if setjmp/longjmp is used). With C++ we can asume less, since foo may throw an exception, and x might implicitly be passed by reference.

The more powerful the macro system the less we can assume. Maybe it's pulling variables from the calling scope and changing them? Maybe it's returning from the current context? Maybe it's formatting the drive? Who knows. You need to know the exact definition or you can't read the local code and this undermines the idea of most languages.

Because in a typical language you will what "breaks the rules": all the built in statements like if, for and return. Then there is a way to extend the language that follows certain rules: functions and types. This forms the common language understood by a developer to be what "knowing a language is about": you know the syntax and semantics of the built-in statements.

If the language extends its syntax, then every code base becomes a DSL which you have to learn from scratch. This is similar to having to buy into some huge framework in the JS/Java-space, just worse.

The point is that while we're always extending the syntax of the language, doing this through certain limited mechanisms like functions works well, but the more unbounded the extension mechanisms the harder the code will be to read and understand.

When meta programming is needed

In some cases meta programming can make code more readable. If the problem is something like having a pre-calculated list for fast calculations or types defined from a protocol, then code generation can often solve the problem. Languages can improve this by better compiler support for triggering codegen.

In other cases the meta programming can be replaced by running code at startup. Having "static init" like Java static blocks can help for cases when libraries need to do initialization.

If none of those options work, there is always copy-paste.

Summary

So to summarize:

  • Code with meta programming is hard to read (so minimize and support readability).
  • Meta programming is hard to refactor (so adopt a subset that can work with IDEs).
  • Arbitrary type creation is hard for tools (so restrict it to generics).
  • Same syntax is bad (so make meta code distinct).
  • Extending syntax with macros is bad (so don't do it).
  • Codegen and init at runtime can replace some use of compile time.

Macros and compile time can be made extremely powerful, but this power is tempered by the huge drawbacks, good macros are not what you can do with them, but if it manages to balance readability with necessary features.


Comment by Christoffer Lernö

Having macro meta syntax that is different from the regular syntax helps, but whenever compile time and runtime code mix, the readability goes down. Trying to keep two sets of states in your head at the same time is not trivial and affects code reading.

If you do plain code generation with a code generator (and actually produce a final source file), you have less restrictions on the how you express this code generation as opposed to having code generation in the same code you're running the code in.

If you run code at startup, rather than run it during compile time you will have an easier time understanding it and inspecting what it produces.

And so on.

Using compile time evaluation for these things is creating a very generic in-language tool, and such tools will by necessity be less easy to work with than a specialized tool (such as a custom code generator). These drawbacks need to be taken into account and be balanced against advantages.

"auto" is a language design smell

Originally from: https://c3.handmade.network/blog/p/8587-auto_is_a_language_design_smell

It's increasingly popular to use type inference for variable declarations.

– and it's understandable, after all who wants to write something like Foobar<Baz<Double, String>, Bar> more than once?

I would argue that "auto" (or your particular language's equivalent) is an anti-pattern when the type is fully known.

When is type inference used?

Few are arguing for replacing:

int i = get_val();

by

auto i = get_val();

The latter is longer and gives less information. Still, some "auto all the things!" fanatics argue that this is right. Because maybe at some time you change what get_val() returns and then you need to change one less place, so now rather than having a syntax error where the function is invoked you get it later at some other place to make it extra hard to debug...

But most people will argue it's mainly for when the type gets complex. For example:

std::map<std::string,std::vector<int> >::iterator it = myMap.begin();
// vs
auto it = myMap.begin();

Another important use is when you write macros or templates and the type has to be inferred. Here's a C3 example:

// No type inference
macro @swap1(&a, &b)
{
  $typeof(a) temp = a;
  a = b;
  b = temp; 
}
// vs
macro @swap2(&a, &b)
{
  var temp = a;
  a = b;
  b = temp; 
}

So we have two common cases:

  • When type is unknown
  • When the type name grows long and complex.

Where do long type names come from?

No one is arguing against the use of type inference when the type isn't known or generic – this use makes perfect sense.

– But there is a problem with the auto it = myMap.begin() use, where type inference is a desired shorthand to only because the type names are too long.

Type names only become long because parameterized types usually carry their parameterization in their type (well, some Java "enterprise" code manages long type names anyway, but that's beside the point).

This inevitably causes type signatures to blow up. It's usually possible to write typedefs to make the types shorter, but few are doing that because it's convenient to just define the type directly with parameters as opposed to doing type defines, plus sometimes the parameterization is actually helpful to determine if it matches a particular generic function.

So basically the way we parameterize types in most languages cause the type name blowup that is then mitigated with type inference.

Again, the problem with type inference

I'm not going to rehash the arguments made here: https://austinhenley.com/blog/typeinference.html. I am mostly in agreement with them.

I think the most important thing is that the type declarations locally documents the assumptions in the code. If I ever need to "hover over a variable in the IDE to find the type" (as some suggest as a solution), it means that it is unclear from the local code context what the type is. Since the type of a variable is fundamental to how the code works, this should never be unclear – which is why the type declaration serves as strong support for code reading. (Explicit variable types also makes it easy to text search for type usage and for the IDE to track types).

While this is bad, the problem with long type signatures often makes up for it. Type inference becomes a necessary because of how parameterized types work.

I would strongly object the idea of introducing type inference it to languages that don't have issues with long type names, such as C (or C3), because fundamentally it is something that will make to code less clear to read and consequently: bugs harder to catch.

The design smell

"auto" is a language design smell because it is typically a sign of the language having types parameterized in a way that makes them inconveniently long.

The type inference thus becomes a language design band-aid which lets people ignore tackling the very real issue of long type names.

If long type names are bad, why is everyone doing it?

Unfortunately there is an added complication: there aren't many good alternatives. Enforcing something like typedefs to use parameterized types works but is not particularly elegant.

There are other possibilities that could be explored, such as eliding the parameterization completely, but retaining the rest of the type (e.g. iterator it = myMap.begin) and similar ideas that straddle both inference and types trying to get the best of both worlds.

Such explorations are uncommon though, which the "auto" style type inference is probably to blame for. A popular band-aid is easier to apply than to find a more innovative solution.

Comments


Comment by Christoffer Lernö

It's increasingly popular to use type inference for variable declarations.

– and it's understandable, after all who wants to write something like Foobar<Baz<Double, String>, Bar> more than once?

I would argue that "auto" (or your particular language's equivalent) is an anti-pattern when the type is fully known.

When is type inference used?

Few are arguing for replacing:

int i = get_val();

by

auto i = get_val();

The latter is longer and gives less information. Still, some "auto all the things!" fanatics argue that this is right. Because maybe at some time you change what get_val() returns and then you need to change one less place, so now rather than having a syntax error where the function is invoked you get it later at some other place to make it extra hard to debug...

But most people will argue it's mainly for when the type gets complex. For example:

std::map<std::string,std::vector<int> >::iterator it = myMap.begin();
// vs
auto it = myMap.begin();

Another important use is when you write macros or templates and the type has to be inferred. Here's a C3 example:

// No type inference
macro @swap1(&a, &b)
{
  $typeof(a) temp = a;
  a = b;
  b = temp; 
}
// vs
macro @swap2(&a, &b)
{
  var temp = a;
  a = b;
  b = temp; 
}

So we have two common cases:

  • When type is unknown
  • When the type name grows long and complex.

Where do long type names come from?

No one is arguing against the use of type inference when the type isn't known or generic – this use makes perfect sense.

– But there is a problem with the auto it = myMap.begin() use, where type inference is a desired shorthand to only because the type names are too long.

Type names only become long because parameterized types usually carry their parameterization in their type (well, some Java "enterprise" code manages long type names anyway, but that's beside the point).

This inevitably causes type signatures to blow up. It's usually possible to write typedefs to make the types shorter, but few are doing that because it's convenient to just define the type directly with parameters as opposed to doing type defines, plus sometimes the parameterization is actually helpful to determine if it matches a particular generic function.

So basically the way we parameterize types in most languages cause the type name blowup that is then mitigated with type inference.

Again, the problem with type inference

I'm not going to rehash the arguments made here: https://austinhenley.com/blog/typeinference.html. I am mostly in agreement with them.

I think the most important thing is that the type declarations locally documents the assumptions in the code. If I ever need to "hover over a variable in the IDE to find the type" (as some suggest as a solution), it means that it is unclear from the local code context what the type is. Since the type of a variable is fundamental to how the code works, this should never be unclear – which is why the type declaration serves as strong support for code reading. (Explicit variable types also makes it easy to text search for type usage and for the IDE to track types).

While this is bad, the problem with long type signatures often makes up for it. Type inference becomes a necessary because of how parameterized types work.

I would strongly object the idea of introducing type inference it to languages that don't have issues with long type names, such as C (or C3), because fundamentally it is something that will make to code less clear to read and consequently: bugs harder to catch.

The design smell

"auto" is a language design smell because it is typically a sign of the language having types parameterized in a way that makes them inconveniently long.

The type inference thus becomes a language design band-aid which lets people ignore tackling the very real issue of long type names.

If long type names are bad, why is everyone doing it?

Unfortunately there is an added complication: there aren't many good alternatives. Enforcing something like typedefs to use parameterized types works but is not particularly elegant.

There are other possibilities that could be explored, such as eliding the parameterization completely, but retaining the rest of the type (e.g. iterator it = myMap.begin) and similar ideas that straddle both inference and types trying to get the best of both worlds.

Such explorations are uncommon though, which the "auto" style type inference is probably to blame for. A popular band-aid is easier to apply than to find a more innovative solution.

The case against a C alternative

Originally from: https://c3.handmade.network/blog/p/8486-the_case_against_a_c_alternative

Like several others I am writing an alternative to the C language (if you read this blog before then this shouldn't be news!). My language (C3) is fairly recent, there are others: Zig, Odin, Jai and older languages like eC. Looking at C++ alternatives there are languages like D, Rust, Nim, Crystal, Beef, Carbon and others.

But is it possible to replace C? Let's consider some arguments against.

1. C language toolchain

The C language is not just the language itself but all the developer tools developed for the language. Do you want to do static analysis on your source code? - There are a lot of people working on that for C. Tools for detecting memory leaks, data races and other bugs? There's a lot of those, even if your language has better tooling out of the box.

If you want to target some obscure platform, then likely it's assuming you're using C.

The status of C as the lingua franca of today's computing makes it worthwhile to write tools for it, so there are many tools being written.

If someone has a toolchain set up working, why risk it switching language? A "better C" must bring a lot of added productivity to motivate the spending time setting up a new toolchain. If it's even possible.

2. The uncertainties of a new language

Before a language has matured, it's likely to have bugs and might change significantly to address problems with language semantic. And is the language even as advertised? Maybe it offers something like "great compile times" or "faster than C" – only these goals turn out to be hard to reach a the language adds the full set of features.

And what about maintainers? Sure, an open source language can be forked, but I doubt many companies are interested in using a language that they further down the line might be forced to maintain.

Betting on a new language is a big risk.

3. The language might just not be good enough

Is the language even addressing the real pain points of C? It turns out that people don't always agree on what the pain points with C is. Memory allocation, array and string handling are often tricky, but with the right libraries and a sound memory strategy, it can be minimized. Is the language possibly addressing problems that advanced users don't really worry about – if so then its actual value might be much lower than expected.

And worse, what if the language omits crucial features that are present in C? Features that C advanced programmers rely on? This risk is increased if the language designer hasn't used C a great deal but comes from C++, Java etc.

4. No experienced developers

A new language will naturally have a much smaller pool of experienced developers. For any middle to large company that's a huge problem. The more developers there are available for a company, the better they like it.

Also, while the company has experience recruiting for C developers, it doesn't know how to recruit for this new language.

5. The C ABI is the standard for interoperability

If the language can't easily call – or be called - by C code, then anyone using the language will have to have to do extra work to do pretty much anything that is interfacing with outside code. This is potentially a huge disadvantage.

"Better X" doesn't matter

So those are some of the downsides to not picking C, to be offset by the advantages of picking the alternative. However, often language designers over-estimate what how big advantages their added "features" bring. Here are some common "false advantages"

1. Better syntax

Having a "better syntax" than C is mostly subjective. Different syntax is also a huge disadvantage: now you can't copy code from C, you might have to rewrite every single line even. No company will adopt a language because it has slightly better syntax than C.

2. Safer than C

Any C alternative will be expected to be on par with C in performance. The problem is that C have practically no checks, so any safety checks put into the competing language will have a runtime cost, which often is unacceptable. This leads to a strategy of only having checks in "safe" mode. Where the "fast" mode is just as "unsafe" as C.

There are some exceptions: "foreach" avoids manually adding boundary checks and so will automatically be safer. Similarly slices helps in writing checks compared to "pointer + len" (or worse: null terminated arrays).

3. Programmer productivity

First of all, pretty much all languages ever will make vacuous claims of "higher programmer productivity". The problem is that for a business this usually doesn't matter. Why? Because the actual programming is not the main time sink. In a business, what takes time is to actually figure out what the task really is. So something like a 10% or 20% "productivity boost" won't even register. A 100% increase in productivity might show, but even that isn't guaranteed.

What matters?

So if these don't matter, what does? For a business it's whether despite the downsides the language can help the bottom line: "Is this valuable enough to outweigh the downsides?"

But if "better x" doesn't help - what does? Well... "killer features": having unique selling points that C can't match.

Look at Java, when it was released it offered the following features that most of the competing languages couldn't give you:

  • OO done cleanly (OO was hot at the time)
  • Threading out of the box (uncommon at the time)
  • "Write once run anywhere"
  • "Run your code in the browser"
  • Garbage collection built in
  • Network programming
  • Good standard library
  • Free to use

That's not just one but eight(!) killer features. How many of those unique selling points do the C alternatives have? Less than Java did at least!

The next killing feature

So my argument is that a common way languages gets adoption by being the only language in order to use something: Dart for using Flutter, JS for scripting the browser, Java for applets, ObjC for Mac and iOS apps.

Even if those monopolies disappear over time, it gives the language become known and used.

Similarly there are examples where frameworks have been popular enough to boost languages, Ruby and Python are good examples.

So looking at our example languages, Jai's strategy of bundling a game engine seems good: anyone using it will have to learn Jai, so if the engine is good enough people will learn the language too.

But aside from Jai, is anyone C alternative really looking to pursue having killer features? And if it doesn't have one, how does it prove the switch from C is worth it? It can't.

Conclusion

The "build it and they will come" idea is tempting to believe in, but there is frankly little reason to drop C unless the alternative has important unique features an/or products that C can't hope to match.

While popularity and enthusiasm is helpful, it cannot replace proven value. In the end, all that matters is whether using a language can produce more tangible value to developers than C for at least a large subset of what C is used for. While developers may be excited by new languages, that enthusiasm doesn't translate to business value.

So no matter how exciting that C alternative may look, it probably will fail.

Comments


Comment by Christoffer Lernö

Like several others I am writing an alternative to the C language (if you read this blog before then this shouldn't be news!). My language (C3) is fairly recent, there are others: Zig, Odin, Jai and older languages like eC. Looking at C++ alternatives there are languages like D, Rust, Nim, Crystal, Beef, Carbon and others.

But is it possible to replace C? Let's consider some arguments against.

1. C language toolchain

The C language is not just the language itself but all the developer tools developed for the language. Do you want to do static analysis on your source code? - There are a lot of people working on that for C. Tools for detecting memory leaks, data races and other bugs? There's a lot of those, even if your language has better tooling out of the box.

If you want to target some obscure platform, then likely it's assuming you're using C.

The status of C as the lingua franca of today's computing makes it worthwhile to write tools for it, so there are many tools being written.

If someone has a toolchain set up working, why risk it switching language? A "better C" must bring a lot of added productivity to motivate the spending time setting up a new toolchain. If it's even possible.

2. The uncertainties of a new language

Before a language has matured, it's likely to have bugs and might change significantly to address problems with language semantic. And is the language even as advertised? Maybe it offers something like "great compile times" or "faster than C" – only these goals turn out to be hard to reach a the language adds the full set of features.

And what about maintainers? Sure, an open source language can be forked, but I doubt many companies are interested in using a language that they further down the line might be forced to maintain.

Betting on a new language is a big risk.

3. The language might just not be good enough

Is the language even addressing the real pain points of C? It turns out that people don't always agree on what the pain points with C is. Memory allocation, array and string handling are often tricky, but with the right libraries and a sound memory strategy, it can be minimized. Is the language possibly addressing problems that advanced users don't really worry about – if so then its actual value might be much lower than expected.

And worse, what if the language omits crucial features that are present in C? Features that C advanced programmers rely on? This risk is increased if the language designer hasn't used C a great deal but comes from C++, Java etc.

4. No experienced developers

A new language will naturally have a much smaller pool of experienced developers. For any middle to large company that's a huge problem. The more developers there are available for a company, the better they like it.

Also, while the company has experience recruiting for C developers, it doesn't know how to recruit for this new language.

5. The C ABI is the standard for interoperability

If the language can't easily call – or be called - by C code, then anyone using the language will have to have to do extra work to do pretty much anything that is interfacing with outside code. This is potentially a huge disadvantage.

"Better X" doesn't matter

So those are some of the downsides to not picking C, to be offset by the advantages of picking the alternative. However, often language designers over-estimate what how big advantages their added "features" bring. Here are some common "false advantages"

1. Better syntax

Having a "better syntax" than C is mostly subjective. Different syntax is also a huge disadvantage: now you can't copy code from C, you might have to rewrite every single line even. No company will adopt a language because it has slightly better syntax than C.

2. Safer than C

Any C alternative will be expected to be on par with C in performance. The problem is that C have practically no checks, so any safety checks put into the competing language will have a runtime cost, which often is unacceptable. This leads to a strategy of only having checks in "safe" mode. Where the "fast" mode is just as "unsafe" as C.

There are some exceptions: "foreach" avoids manually adding boundary checks and so will automatically be safer. Similarly slices helps in writing checks compared to "pointer + len" (or worse: null terminated arrays).

3. Programmer productivity

First of all, pretty much all languages ever will make vacuous claims of "higher programmer productivity". The problem is that for a business this usually doesn't matter. Why? Because the actual programming is not the main time sink. In a business, what takes time is to actually figure out what the task really is. So something like a 10% or 20% "productivity boost" won't even register. A 100% increase in productivity might show, but even that isn't guaranteed.

What matters?

So if these don't matter, what does? For a business it's whether despite the downsides the language can help the bottom line: "Is this valuable enough to outweigh the downsides?"

But if "better x" doesn't help - what does? Well... "killer features": having unique selling points that C can't match.

Look at Java, when it was released it offered the following features that most of the competing languages couldn't give you:

  • OO done cleanly (OO was hot at the time)
  • Threading out of the box (uncommon at the time)
  • "Write once run anywhere"
  • "Run your code in the browser"
  • Garbage collection built in
  • Network programming
  • Good standard library
  • Free to use

That's not just one but eight(!) killer features. How many of those unique selling points do the C alternatives have? Less than Java did at least!

The next killing feature

So my argument is that a common way languages gets adoption by being the only language in order to use something: Dart for using Flutter, JS for scripting the browser, Java for applets, ObjC for Mac and iOS apps.

Even if those monopolies disappear over time, it gives the language become known and used.

Similarly there are examples where frameworks have been popular enough to boost languages, Ruby and Python are good examples.

So looking at our example languages, Jai's strategy of bundling a game engine seems good: anyone using it will have to learn Jai, so if the engine is good enough people will learn the language too.

But aside from Jai, is anyone C alternative really looking to pursue having killer features? And if it doesn't have one, how does it prove the switch from C is worth it? It can't.

Conclusion

The "build it and they will come" idea is tempting to believe in, but there is frankly little reason to drop C unless the alternative has important unique features an/or products that C can't hope to match.

While popularity and enthusiasm is helpful, it cannot replace proven value. In the end, all that matters is whether using a language can produce more tangible value to developers than C for at least a large subset of what C is used for. While developers may be excited by new languages, that enthusiasm doesn't translate to business value.

So no matter how exciting that C alternative may look, it probably will fail.

Optional syntax

Originally from: https://c3.handmade.network/blog/p/8460-optional_syntax

In C3 optionals are built into the language. They're not the run of the mill optionals as they carry a "optional result value". This makes them more like "Result" types than optionals.

In C3 you declare a variable holding an optional using the ! suffix:

int! x = ...

We can now assign either to the real value, or to the optional result:

int! x = 1; // x is a real value
x = MyRes.MISSING!; // x is assigned an optional
// x = MyRes.MISSING; <- Error: cannot assign "MyRes" to int

If we think of it in terms of a "Result":

Result<int> x;
x.result = 1; // x = 1
x.error = MyRes.ERR; // x = MyRes.ERR!
x.result = MyRes.ERR; // x = MyRes.ERR - fails

So the "clever" ! suffix here is used to assign to the "error" part of the Result. Unfortunately, the suffix is hard to read at the end of a line, where ! and ; often blurs together. For that reason I regularly try to revisit this syntax to see if I can improve on it.

It's used in two cases:

  1. assignment: x = MyRes.ERR!
  2. return: return MyRes.ERR!

While (2) could be replaced by something like return! MyRes.ERR or even raise MyRes.ERR, the assignment is not as easily tackled.

Naive ideas could be to use some symbol salad like:

int! x !!= MyRes.MISSING;
int! x <!= MyRes.MISSING;
int! x <- MyRes.MISSING;
// I'm going to exclude
// int! x := MyRes.MISSING
// as it is used as regular assign in most languages.

Or we could allow those return statements to have a different meaning in an assignment:

int! x = raise MyRes.MISSING;
int! x = return! MyRes.MISSING;

While it's possible, it creates an odd effect if we consider this example:

int! x;
return x = return! MyRes.MISSING;

This should also illustrate that using x = MyRes.MISSING! should be thought of as implicitly doing x = { 0, MyRes.MISSING }.

Understanding that we see how it works:

x = MyRes.MISSING!; // x = { 0, MyRes.MISSING }
return MyRes.MISSING!; // return { 0, MyRes.MISSING }

So really the proper way would be to always translate the !, like this:

x = fault MyRes.ERR;
return fault MyRes.ERR;

Which is a mouthful. One could of course contract that return fault into something like:

x = fault MyRes.ERR;
throw MyRes.ERR;

Unfortunately, this builds the assumption that a return may not return an optional, which it of course can:

int! x = ...
return x; // Optional, so it may be like a "throw" or not

If we want to be super clear we can do something like this:

int! x = ...
if (y) return? x; // Might return an optional
if (z) return! MyRes.MISSING; // Will return an optional.
if (w) return w; // Will not return an optional

Due to the ? being a rethrow, we could require this:

int! x = ...
if (y) return x?; // Use rethrow to make the type int
if (z) return! MyRes.MISSING; // Will return an optional.
if (w) return w; // Will not return an optional

So the question here is if this adds anything over the original:

int! x = ...
if (y) return x;
if (z) return MyRes.MISSING!; 
if (w) return w;

These are questions that need quite a bit of C3 error handling code to decide, so for now things have to stay as they are.

Comments


Comment by Christoffer Lernö

In C3 optionals are built into the language. They're not the run of the mill optionals as they carry a "optional result value". This makes them more like "Result" types than optionals.

In C3 you declare a variable holding an optional using the ! suffix:

int! x = ...

We can now assign either to the real value, or to the optional result:

int! x = 1; // x is a real value
x = MyRes.MISSING!; // x is assigned an optional
// x = MyRes.MISSING; <- Error: cannot assign "MyRes" to int

If we think of it in terms of a "Result":

Result<int> x;
x.result = 1; // x = 1
x.error = MyRes.ERR; // x = MyRes.ERR!
x.result = MyRes.ERR; // x = MyRes.ERR - fails

So the "clever" ! suffix here is used to assign to the "error" part of the Result. Unfortunately, the suffix is hard to read at the end of a line, where ! and ; often blurs together. For that reason I regularly try to revisit this syntax to see if I can improve on it.

It's used in two cases:

  1. assignment: x = MyRes.ERR!
  2. return: return MyRes.ERR!

While (2) could be replaced by something like return! MyRes.ERR or even raise MyRes.ERR, the assignment is not as easily tackled.

Naive ideas could be to use some symbol salad like:

int! x !!= MyRes.MISSING;
int! x <!= MyRes.MISSING;
int! x <- MyRes.MISSING;
// I'm going to exclude
// int! x := MyRes.MISSING
// as it is used as regular assign in most languages.

Or we could allow those return statements to have a different meaning in an assignment:

int! x = raise MyRes.MISSING;
int! x = return! MyRes.MISSING;

While it's possible, it creates an odd effect if we consider this example:

int! x;
return x = return! MyRes.MISSING;

This should also illustrate that using x = MyRes.MISSING! should be thought of as implicitly doing x = { 0, MyRes.MISSING }.

Understanding that we see how it works:

x = MyRes.MISSING!; // x = { 0, MyRes.MISSING }
return MyRes.MISSING!; // return { 0, MyRes.MISSING }

So really the proper way would be to always translate the !, like this:

x = fault MyRes.ERR;
return fault MyRes.ERR;

Which is a mouthful. One could of course contract that return fault into something like:

x = fault MyRes.ERR;
throw MyRes.ERR;

Unfortunately, this builds the assumption that a return may not return an optional, which it of course can:

int! x = ...
return x; // Optional, so it may be like a "throw" or not

If we want to be super clear we can do something like this:

int! x = ...
if (y) return? x; // Might return an optional
if (z) return! MyRes.MISSING; // Will return an optional.
if (w) return w; // Will not return an optional

Due to the ? being a rethrow, we could require this:

int! x = ...
if (y) return x?; // Use rethrow to make the type int
if (z) return! MyRes.MISSING; // Will return an optional.
if (w) return w; // Will not return an optional

So the question here is if this adds anything over the original:

int! x = ...
if (y) return x;
if (z) return MyRes.MISSING!; 
if (w) return w;

These are questions that need quite a bit of C3 error handling code to decide, so for now things have to stay as they are.

Why implicit imports fails

Originally from: https://c3.handmade.network/blog/p/8448-why_implicit_imports_fails

As previously discussed, it might be possible to do implicit imports so using Foo would implicitly do it. In C3 due to the overall rules, this leads to few ambiguities (go back to the blog post to review how it works)

After using this for quite a while, I ended up concluding that full implicit imports are bad. You want enough high level importing to feel that there is some documentation of what is included to hint at the possible origin of types.

An example is when read some code that relies on an external graphics library and you encounter a type like Point or Vector2. Because at that point you can't be sure whether this is a type from the external library or from some obscure part of the standard library. Same with something like Socket or Connection: is that Socket from a standard lib networking library, or is it from some external imported library? If the standard library is big enough then you can't know for sure – and finding out is not easy.

So you want at least a high level import, but possibly not import std::net::socket granularity, but rather something like import std::net or import raylib at the top of the file – enough to make it easy to find the types and functions.

So the new updated scheme has wildcard inclusion by default (so import std::net would include all the sub modules).

In addition, I've also made modules implicitly import any other module with the same top domain. So code in std::net::socket would see the code in std::net::http without the need for an explicit import.

This means that if you start a project with some top module, for example mygame, then in the module mygame::gameloop you'll automatically import mygame::maths and mygame::data.

There are some issues with the latter. In particular, all of the standard library modules would see all other standard library modules! It's quite possible to address that, but first I want to make sure it's a problem in practice. Even completely implicit imports "almost worked", so maybe this isn't much of a problem.

Comments


Comment by Christoffer Lernö

As previously discussed, it might be possible to do implicit imports so using Foo would implicitly do it. In C3 due to the overall rules, this leads to few ambiguities (go back to the blog post to review how it works)

After using this for quite a while, I ended up concluding that full implicit imports are bad. You want enough high level importing to feel that there is some documentation of what is included to hint at the possible origin of types.

An example is when read some code that relies on an external graphics library and you encounter a type like Point or Vector2. Because at that point you can't be sure whether this is a type from the external library or from some obscure part of the standard library. Same with something like Socket or Connection: is that Socket from a standard lib networking library, or is it from some external imported library? If the standard library is big enough then you can't know for sure – and finding out is not easy.

So you want at least a high level import, but possibly not import std::net::socket granularity, but rather something like import std::net or import raylib at the top of the file – enough to make it easy to find the types and functions.

So the new updated scheme has wildcard inclusion by default (so import std::net would include all the sub modules).

In addition, I've also made modules implicitly import any other module with the same top domain. So code in std::net::socket would see the code in std::net::http without the need for an explicit import.

This means that if you start a project with some top module, for example mygame, then in the module mygame::gameloop you'll automatically import mygame::maths and mygame::data.

There are some issues with the latter. In particular, all of the standard library modules would see all other standard library modules! It's quite possible to address that, but first I want to make sure it's a problem in practice. Even completely implicit imports "almost worked", so maybe this isn't much of a problem.

Imports and modules

Originally from: https://c3.handmade.network/blog/p/8417-imports_and_modules

When talking about packages / modules, I think it's useful to start with Java. As a language C/C++ but with an import / module system from the beginning, it ended up being a very influential.

Importing a namespace or a graph

Interestingly, the import statement in Java doesn't actually import anything. It's a simple namespace folding mechanism, allowing you to use something like java.util.Random as just Random. The fact that you can use the fully qualified name somewhere later in the source code to implicitly use another package, means that the imports do not fully define the dependencies of a Java source file.

In Java, given a collection of source files, all must be compiled to determine the actual dependencies. However, we can imagine instead a different model where the import statements create a dependency graph, starting from the source file that is the main entry point. In this model we may have N source files, but not all are even compiled, since only the subset M can be reached from the import graph.

This later model allows some extra features. For example we can build the feature where including a source file may also implicitly cause a dynamic or static library to be linked. Because only the source code in the graph is compiled, we'll then only get the extra link parameter if the imports reach the source file with the parameter.

The disadvantage is that the imports need to have a clear way of finding the additional dependencies. This is typically done with a file hierarchy or strict naming scheme, so that importing foo.bar allows the compiler to easily find the file or files that define that particular module.

Folding the import

For module systems that allow sub modules, so that there's both foo.bar and foo.baz, the problem with verbosity appears: do we really want to type std.io.net.Socket everywhere? I think the general consensus is that this is annoying.

The two common ways to solve this are namespace folding and namespace renaming, but I'm going to present one more which I term namespace shortening.

The namespace folding is the easiest. You import std.io.net and now you can use Socket unqualified. This is how it works in Java. However, we should note that in Java any global or function is actually prefixed with the class name, which means that even when folding the namespace, your globals and "functions" (static methods) ends up having a prefix.

To overcome collisions and shortcomings of namespace folding, there's namespace renaming, where the import explicitly renames the module name in the file scope, so std.io.net might become n and you now use n.Socket rather than the fully folded or fully qualified name. The downside is naming this namespace alias. Naming things well is known to be one of the harder things in programming, and it can also add to the confusion if the alias is chosen to be different in different parts of the program, e.g. n.Socket in one file and netio.Socket in another.

A way to address the renaming problem is to recognize that usually only the last namespace element is sufficient to distinguish one function from another, so we can allow an abbreviated namespace, allowing the shortened namespace to be used in place of the full one. With this scheme std.io.net.open_socket(), io.net.open_socket() and net.open_socket() are all valid as long as there is no ambiguity (for example, if an import made foo.net.open_socket() available in the current scope, then net.open_socket() would be ambiguous and a longer path, like io.net.open_socket() would be required). C3 uses this scheme for all globals, functions and macros and it seems successful so far.

Lots of imports

In Java, imports quickly became fairly onerous to write, since using a class foo.bar.Baz would use another class from foo.bar.Bar and now both needed to be imported. While wildcard imports helped a bit, those would pull in more classes than necessary, and so inspecting the import statements would obfuscate the actual dependencies.

As a workaround, languages like D added the concept of re-exported imports (D calls this feature "public imports"). So in our foo.bar.Baz case, it could import foo.bar.Bar and re-export it. So that an import of foo.bar.Baz implicitly imports foo.bar.Bar as well. The downside here again is that it's not possible from looking at the imports to see what the actual dependencies are.

A related feature is implicit imports determined by the namespace hierarchy. So for example in Java, any source file in the package foo.bar.baz has all the classes of foo.bar implicitly folded into its namespace. This folding goes bottom up, but not the other way around. So while foo.bar.baz.AbcClass sees foo.bar.Baz, Baz can't access foo.bar.baz.AbcClass without an explicit import.

An experiment: no imports

For C3 I wanted to try going completely without imports. This was feasible mainly due to two observations: (1) type names tend to be fairly universally unique (2) methods and globals are usually unique with a shortened namespace. So given, Foo and foo::some_function() these should mostly be unique without the need for imports. So this is a completely implicit import scheme.

This is completmented by the compiler requiring the programmer to explicitly say which libraries should be used for compilation. So imports could be said to be done globally for the whole program in the build settings.

This certainly works, but has a drawback: let's say a program relies on a library like Raylib. Now Raylib in itself will create a lot of types and functions and while it's no problem to resolve them, it could make it confusing for a casual reader "Oh, a Vector2, is this part of the C3 standard library?", whereas having an import raylib; at the top would immediately hint to the reader where Vector2 might be found.

Wildcard imports for all?

The problem with zero imports suggests an alternative of wildcard imports as the default, so import raylib; would be the standard type of imports and would recursively import everything in raylib, and similarly import std; would get the whole standard library. This would be more for the reader of the code to find the dependencies than being necessary for the compiler.

One problem with this design are the sub modules visibility rules: "what does foo::bar::baz and foo::bar see?"

Java would allow foo::bar::baz to see the foo::bar parent module, but not vice versa. However, looking at the actual usage patterns, it seems to make sense to make this bidirectional, so that all are visible to each other.

But if parent and children modules are visible to each other, what about sibling modules? E.g. does foo::bar::baz see foo::bar::abc? In actual usecases there are arguments both for and against. But if we have sibling visibility what about foo::def and foo::bar::abc? Could they be visible to each other? And if not, would such rules get complicated?

To create a more practical scenario, imagine that we have the following:

  1. std::io::file::open_filename_for_read() a function to open a file for reading
  2. std::io::Path representing a general path.
  3. std::io::OpenMode a distinct type for a mask value for file or resource opening
  4. std::io::readoptions::READ_ONLY a constant of type OpenMode

Let's say this is the implementation of (1)

fn File* open_filename_for_read(char[] filename)
{
  Path* p = io::path_from_string(foo);
  defer io::path_free(p);
  return file::open_file(p, readoptions::READ_ONLY);
}

Here we see that std::io::file must be able to use std::io and std::io::readoptions. The readoptions sub module needs std::io but not the file sub module. Note how C3 uses a function in a sub module as other languages would typically use static methods. If we want to avoid excessive imports in this case, then file would need sibling and parent visibility, whereas the readoptions use only requires parent visibility.

Excessive rules around visibility is both hard to implement well, hard to test and hard to remember, so it might be preferrable to simply say that a module has visibility to any other module in the same top module. The downside would of course be that visibility is much wider than what's probably desired (e.g. std::math having visibility to std::io).

Conclusions and further research for C3

Like everything in language design, imports and modules have a lot of trade-offs. Import statements may be used to narrow down the dependency graph, but at the same time a language with a lot of imports don't necessarily use them in that manner. For namespace folding it matters a lot whether functions are usually grouped as static methods or free functions. Imports can be used to implicitly determine things like linking arguments, in which case the actual import graph matters.

For C3, the scheme with implicit imports works thanks to library imports also being restricted by build scripts, but high level imports could still improve readability. However such a scheme would probably need recursive imports which raises the question of implicit imports between sub modules. For C3 in particular this an important usability concern as sub modules are used to organize functions and constants more than is common in many other languages. This is the area I'm currently researching, but I hope that within a few weeks I can have a design candidate.

Comments


Comment by Christoffer Lernö

When talking about packages / modules, I think it's useful to start with Java. As a language C/C++ but with an import / module system from the beginning, it ended up being a very influential.

Importing a namespace or a graph

Interestingly, the import statement in Java doesn't actually import anything. It's a simple namespace folding mechanism, allowing you to use something like java.util.Random as just Random. The fact that you can use the fully qualified name somewhere later in the source code to implicitly use another package, means that the imports do not fully define the dependencies of a Java source file.

In Java, given a collection of source files, all must be compiled to determine the actual dependencies. However, we can imagine instead a different model where the import statements create a dependency graph, starting from the source file that is the main entry point. In this model we may have N source files, but not all are even compiled, since only the subset M can be reached from the import graph.

This later model allows some extra features. For example we can build the feature where including a source file may also implicitly cause a dynamic or static library to be linked. Because only the source code in the graph is compiled, we'll then only get the extra link parameter if the imports reach the source file with the parameter.

The disadvantage is that the imports need to have a clear way of finding the additional dependencies. This is typically done with a file hierarchy or strict naming scheme, so that importing foo.bar allows the compiler to easily find the file or files that define that particular module.

Folding the import

For module systems that allow sub modules, so that there's both foo.bar and foo.baz, the problem with verbosity appears: do we really want to type std.io.net.Socket everywhere? I think the general consensus is that this is annoying.

The two common ways to solve this are namespace folding and namespace renaming, but I'm going to present one more which I term namespace shortening.

The namespace folding is the easiest. You import std.io.net and now you can use Socket unqualified. This is how it works in Java. However, we should note that in Java any global or function is actually prefixed with the class name, which means that even when folding the namespace, your globals and "functions" (static methods) ends up having a prefix.

To overcome collisions and shortcomings of namespace folding, there's namespace renaming, where the import explicitly renames the module name in the file scope, so std.io.net might become n and you now use n.Socket rather than the fully folded or fully qualified name. The downside is naming this namespace alias. Naming things well is known to be one of the harder things in programming, and it can also add to the confusion if the alias is chosen to be different in different parts of the program, e.g. n.Socket in one file and netio.Socket in another.

A way to address the renaming problem is to recognize that usually only the last namespace element is sufficient to distinguish one function from another, so we can allow an abbreviated namespace, allowing the shortened namespace to be used in place of the full one. With this scheme std.io.net.open_socket(), io.net.open_socket() and net.open_socket() are all valid as long as there is no ambiguity (for example, if an import made foo.net.open_socket() available in the current scope, then net.open_socket() would be ambiguous and a longer path, like io.net.open_socket() would be required). C3 uses this scheme for all globals, functions and macros and it seems successful so far.

Lots of imports

In Java, imports quickly became fairly onerous to write, since using a class foo.bar.Baz would use another class from foo.bar.Bar and now both needed to be imported. While wildcard imports helped a bit, those would pull in more classes than necessary, and so inspecting the import statements would obfuscate the actual dependencies.

As a workaround, languages like D added the concept of re-exported imports (D calls this feature "public imports"). So in our foo.bar.Baz case, it could import foo.bar.Bar and re-export it. So that an import of foo.bar.Baz implicitly imports foo.bar.Bar as well. The downside here again is that it's not possible from looking at the imports to see what the actual dependencies are.

A related feature is implicit imports determined by the namespace hierarchy. So for example in Java, any source file in the package foo.bar.baz has all the classes of foo.bar implicitly folded into its namespace. This folding goes bottom up, but not the other way around. So while foo.bar.baz.AbcClass sees foo.bar.Baz, Baz can't access foo.bar.baz.AbcClass without an explicit import.

An experiment: no imports

For C3 I wanted to try going completely without imports. This was feasible mainly due to two observations: (1) type names tend to be fairly universally unique (2) methods and globals are usually unique with a shortened namespace. So given, Foo and foo::some_function() these should mostly be unique without the need for imports. So this is a completely implicit import scheme.

This is completmented by the compiler requiring the programmer to explicitly say which libraries should be used for compilation. So imports could be said to be done globally for the whole program in the build settings.

This certainly works, but has a drawback: let's say a program relies on a library like Raylib. Now Raylib in itself will create a lot of types and functions and while it's no problem to resolve them, it could make it confusing for a casual reader "Oh, a Vector2, is this part of the C3 standard library?", whereas having an import raylib; at the top would immediately hint to the reader where Vector2 might be found.

Wildcard imports for all?

The problem with zero imports suggests an alternative of wildcard imports as the default, so import raylib; would be the standard type of imports and would recursively import everything in raylib, and similarly import std; would get the whole standard library. This would be more for the reader of the code to find the dependencies than being necessary for the compiler.

One problem with this design are the sub modules visibility rules: "what does foo::bar::baz and foo::bar see?"

Java would allow foo::bar::baz to see the foo::bar parent module, but not vice versa. However, looking at the actual usage patterns, it seems to make sense to make this bidirectional, so that all are visible to each other.

But if parent and children modules are visible to each other, what about sibling modules? E.g. does foo::bar::baz see foo::bar::abc? In actual usecases there are arguments both for and against. But if we have sibling visibility what about foo::def and foo::bar::abc? Could they be visible to each other? And if not, would such rules get complicated?

To create a more practical scenario, imagine that we have the following:

  1. std::io::file::open_filename_for_read() a function to open a file for reading
  2. std::io::Path representing a general path.
  3. std::io::OpenMode a distinct type for a mask value for file or resource opening
  4. std::io::readoptions::READ_ONLY a constant of type OpenMode

Let's say this is the implementation of (1)

fn File* open_filename_for_read(char[] filename)
{
  Path* p = io::path_from_string(foo);
  defer io::path_free(p);
  return file::open_file(p, readoptions::READ_ONLY);
}

Here we see that std::io::file must be able to use std::io and std::io::readoptions. The readoptions sub module needs std::io but not the file sub module. Note how C3 uses a function in a sub module as other languages would typically use static methods. If we want to avoid excessive imports in this case, then file would need sibling and parent visibility, whereas the readoptions use only requires parent visibility.

Excessive rules around visibility is both hard to implement well, hard to test and hard to remember, so it might be preferrable to simply say that a module has visibility to any other module in the same top module. The downside would of course be that visibility is much wider than what's probably desired (e.g. std::math having visibility to std::io).

Conclusions and further research for C3

Like everything in language design, imports and modules have a lot of trade-offs. Import statements may be used to narrow down the dependency graph, but at the same time a language with a lot of imports don't necessarily use them in that manner. For namespace folding it matters a lot whether functions are usually grouped as static methods or free functions. Imports can be used to implicitly determine things like linking arguments, in which case the actual import graph matters.

For C3, the scheme with implicit imports works thanks to library imports also being restricted by build scripts, but high level imports could still improve readability. However such a scheme would probably need recursive imports which raises the question of implicit imports between sub modules. For C3 in particular this an important usability concern as sub modules are used to organize functions and constants more than is common in many other languages. This is the area I'm currently researching, but I hope that within a few weeks I can have a design candidate.