TL;DR: This proposal concerns the overlapping functionality between extern/export decls and the @extern/@export builtins, and the ergonomics of the latter. It is divided up into four smaller sub-proposals which can each be accepted or rejected on an individual basis, but which I believe would synergize well were they all to be accepted.
- Remove
extern and export decls in favor of the more powerful @extern and @export builtins
- Replace the
@extern type parameter with the inferred result type
- Move the
name fields of std.builtin.Extern/ExportOptions to the parameter list of @extern and @export
- Rename
@extern and @export to @externImport and @externExport
As for the "why", doing these would help eliminate a subtle footgun, while also making the builtins easier and more intuitive for users to use.
These ideas were briefly discussed on Zulip and I was given the go-ahead to file a language proposal.
Status quo example
To help illustrate how the proposed changes would affect Zig code, I'll use the following examples of using extern and export and progressively update them with each sub-proposal:
// Import external functions from the SDL3 library, but "ziggify" the function names.pubconstgetWindowTitle=SDL_GetWindowTitle;extern"SDL3"fnSDL_GetWindowTitle(window:*Window)[*:0]constu8;pubconstsetWindowTitle=SDL_SetWindowTitle;extern"SDL3"fnSDL_SetWindowTitle(window:*Window,title:[*:0]constu8)bool;
// Export functions to external code with an 'acme_' prefix, but use non-prefixed names internally.pubconstadd=acme_add;exportfnacme_add(a:c_int,b:c_int)c_int{returna+%b;}pubconstmul=acme_mul;exportfnacme_mul(a:c_int,b:c_int)c_int{returna*%b;}
1. Remove extern and export decls in favor of the more powerful @extern and @export builtins
Problem:
-
Having both extern/export decls and the @extern/@export builtins means that there are two different ways of doing the same things. extern/export decls are strictly less powerful and flexible than the @extern/@export builtins, only supporting the default linkage and visibility options.
If you start off by defining a bunch of extern functions and variables, and later realize that you need to change certain options, you will need to rewrite your extern decls into a form that uses the @extern builtin. This isn't difficult, but can be a bit annoying. Had you used @extern from the start, you would only need to trivially adjust the relevant std.builtin.ExternOptions fields.
-
A slightly greater problem is that extern and @extern are not exactly equivalent. extern var a: *T is a special name binding syntax that will make the Zig variable a an alias for the external symbol a, and the expression &a will return the address of the symbol a. const b = @extern(*T, .{ .name = "a" }) on the other hand will make the Zig variable b a pointer to the external symbol a, and the expression &b will return the address of the Zig variable b, not the the address of the symbol a.
In other words, @extern adds another level of indirection compared to extern. This can be a bit of a footgun. If you already have extern var x: *i32, and you need to rewrite it into the @extern(**i32, .{ .name = "x" }) form, it is very easy to forget to add the second asterisk and end up being one whole level off. Here is a Ziggit thread from a while back where a user was bitten by this. Similar case: #19515.
-
The extern "foo" syntax is easy to get confused with C++'s extern "C". This is obviously a relatively minor issue, but nevertheless it can be a stumbling block for beginners coming to Zig from C/C++.
To make the language surface slightly smaller and to disarm footguns, I propose that the extern/export fn/const/var syntax be removed, leaving the @extern builtin as the only way of declaring a reference to an external symbol and the export builtin as the only way of making a Zig declaration visible to external code. The extern keyword will still retain its use in extern struct/union, but the export keyword no longer has a use and will be deleted from the language.
With these changes, the original examples would need to be rewritten like this:
pubconstgetWindowTitle=@extern(*constfn(window:*Window)callconv(.c)[*:0]constu8,.{.name="SDL_GetWindowTitle",.library_name="SDL3"},);pubconstsetWindowTitle=@extern(*constfn(window:*Window,title:[*:0]constu8)callconv(.c)bool,.{.name="SDL_SetWindowTitle",.library_name="SDL3"},);
pubfnadd(a:c_int,b:c_int)callconv(.c)c_int{returna+%b;}pubfnmul(a:c_int,b:c_int)callconv(.c)c_int{returna*%b;}comptime{@export(&add,.{.name="acme_add"});@export(&mul,.{.name="acme_mul"});}
Some important differences from before:
- The functions must now explicitly be declared with
callconv(.c). I would argue that explicitness is good here. Unless you have read the language spec carefully, it might not obvious that extern and export implicitly cause functions to get declared to use the .c calling convention (when discussing Zig with other users I have had to convince that extern/export work like this on more than one occasion).
set/getWindowTitle() are now pointers to external symbols, not aliases for them. You can technically (sometimes?) dereference them at comptime with @extern(...).* and turn them into aliases; the exact behavior is a bit unclear to me (see also: #21027). Regardless, I think having them be pointers reflects their nature as non-Zig-native symbols better, especially if they are defined as weak or interposable and can be overridden by the linker/loader.
- For each decl, you now need to explicitly specify two names, one for the Zig decl and another for the external symbol. Once again I would argue that explicitness is good; here it helps reinforce to the user that there's a distinction between the Zig-native decl and the external symbol. Compare with other areas of the Zig toolchain that prefer explicitness over implicitness even if it can feel verbose/redundant to the user, such as package names <-> dependency names and module names <-> import names.
However, after looking at the above code you probably think that the const foo = @extern(*const fn () callconv(.c) void, .{ .name = "foo" }) form looks a lot more noisy and awkward compared to the simple extern fn foo() void form. I'm inclined to agree! Which brings us to the next part...
2. Replace the @extern type parameter with the inferred result type
In the recent years, the trend for builtins has been to favor result types over type parameters. Moving the type information from the first parameter to the result type immediately improves the situation by reducing visual clutter:
pubconstgetWindowTitle:*constfn(window:*Window)callconv(.c)[*:0]constu8=@extern(.{.name="SDL_GetWindowTitle",.library_name="SDL3"});pubconstsetWindowTitle:*constfn(window:*Window,title:[*:0]constu8)callconv(.c)bool=@extern(.{.name="SDL_SetWindowTitle",.library_name="SDL3"});
But can we do even better?
3. Move the name fields of std.builtin.Extern/ExportOptions to the parameter list of @extern and @export
If you're referencing external symbols from a library, or if you're exporting your own symbols to external code, it's likely that you will want to use the same options for most/all symbols. However, annoyingly, the name fields of std.builtin.Extern/ExportOptions are used to specify the unique symbol name, which means that you can't define a common options instance and reuse it for all @extern/@export uses. Instead you will need to assign each field explicitly every time.
If we move the symbol name from the name field of the options struct to the @extern/@export parameter list, we can conveniently reuse common options and reduce visual clutter for the reader:
constSDL3:std.builtin.ExternOptions=.{.library_name="SDL3"};pubconstgetWindowTitle:*constfn(window:*Window)callconv(.c)[*:0]constu8=@extern("SDL_GetWindowTitle",SDL3);pubconstsetWindowTitle:*constfn(window:*Window,title:[*:0]constu8)callconv(.c)bool=@extern("SDL_SetWindowTitle",SDL3);
pubfnadd(a:c_int,b:c_int)callconv(.c)c_int{returna+%b;}pubfnmul(a:c_int,b:c_int)callconv(.c)c_int{returna*%b;}comptime{// Setting some options to help illustrate the point.constoptions:std.builtin.ExportOptions=.{.linkage=.weak,.visibility=.protected};@export(&add,"acme_add",options);@export(&mul,"acme_mul",options);}
Both builtins, @extern in particular, now look quite nice and ergonomic to use!
4. Rename @extern and @export to @externImport and @externExport
This is a bit different from the above, but please hear me out.
If you didn't know anything about Zig and saw the names of the builtins @import and @export in isolation, you would probably naturally assume that they are related. After all, they form an antonym pair, and languages like JavaScript use import and export for what you would assume, and in MSVC-flavored C you have __declspec(dllimport) and __declspec(dllexport). However, in actuality, @import and @export are used for two very different kinds of binding/linking: @import only deals with things within the same Zig compilation unit and @export deals with things from the outside world.
Similary, the extern and export keywords don't naturally appear to have anything to do with each other, when in actuality they are sort of the two sides of the same kind of linking. In particular, "this thing is defined elsewhere" is not the only logical conclusion for the meaning of the extern keyword and if you don't have prior familarity with the extern keyword from C it could just as well mean "make this thing available externally". (In fact I still frequently find myself not paying enough attention and typing extern fn instead of export fn.)
Therefore, I would like suggest renaming @extern and @export to @externImport and @externExport respectively, to make it clear and explicit to the user that
- the builtins deal with external code, not Zig code internal to the compilation unit, and
- which side (Zig or external code) provides the symbol.
In other contexts in Zig, the term "extern" means "compatible with the C ABI". @externImport for "receive something from the outside world" and @externExport for "provide something to the outside world" feels intuitive to me.
There might be other candidates for names that would work just as well; however, having the terms "import" and "export" in the names makes the builtins map nicely and intuitively to object formats like PE/COFF and Wasm which use separate tables for dynamic imports/exports.
Lastly, in case you're concerned that @externImport appears similar to but doesn't have much in common with how @import works, I'd like to remind you that @import makes use of result types when importing ZON files, e.g. const config: Config = @import("config.zon"), so they are not as dissimilar as they would have been a few versions back.
With all of the above changes, the final revision of the example becomes this:
constSDL3:std.builtin.externs.ImportOptions=.{.library_name="SDL3"};pubconstgetWindowTitle:*constfn(window:*Window)callconv(.c)[*:0]constu8=@externImport("SDL_GetWindowTitle",SDL3);pubconstsetWindowTitle:*constfn(window:*Window,title:[*:0]constu8)callconv(.c)bool=@externImport("SDL_SetWindowTitle",SDL3);
pubfnadd(a:c_int,b:c_int)callconv(.c)c_int{returna+%b;}pubfnmul(a:c_int,b:c_int)callconv(.c)c_int{returna*%b;}comptime{constoptions:std.builtin.externs.ExportOptions=.{.linkage=.weak,.visibility=.protected};@externExport(&add,"acme_add",options);@externExport(&mul,"acme_mul",options);}
(I chose to rename the std.builtin types to std.builtin.externs.Import/ExportOptions per the Avoid Redundant Names in Fully-Qualified Namespaces advice, but this is only a suggestion.)
Other thoughts
This proposal would eliminate the need for the suggested linkname("foo") and linklibrary("bar") proposed in #19999 and #20006 respectively.
It would probably be possible to have zig fmt automatically rewrite most uses of extern fn in the wild into the corresponding @externImport/@externExport variants without causing too much breakage, since function pointers can be (need to be) called without explicitly dereferencing. extern const/var is a bit more prone to breakage because the decls would now be pointers instead of aliases, but external variables are much less common than external functions. export fn/const/var should be fairly simple to automatically rewrite into a comptime block with an @externExport statement.
**TL;DR:** This proposal concerns the overlapping functionality between `extern`/`export` decls and the `@extern`/`@export` builtins, and the ergonomics of the latter. It is divided up into four smaller sub-proposals which can each be accepted or rejected on an individual basis, but which I believe would synergize well were they all to be accepted.
1. Remove `extern` and `export` decls in favor of the more powerful `@extern` and `@export` builtins
2. Replace the `@extern` type parameter with the inferred result type
3. Move the `name` fields of `std.builtin.Extern/ExportOptions` to the parameter list of `@extern` and `@export`
4. Rename `@extern` and `@export` to `@externImport` and `@externExport`
As for the "why", doing these would help eliminate a subtle footgun, while also making the builtins easier and more intuitive for users to use.
These ideas were briefly discussed on Zulip and I was given the go-ahead to file a language proposal.
### Status quo example
To help illustrate how the proposed changes would affect Zig code, I'll use the following examples of using `extern` and `export` and progressively update them with each sub-proposal:
```zig
// Import external functions from the SDL3 library, but "ziggify" the function names.
pub const getWindowTitle = SDL_GetWindowTitle;
extern "SDL3" fn SDL_GetWindowTitle(window: *Window) [*:0]const u8;
pub const setWindowTitle = SDL_SetWindowTitle;
extern "SDL3" fn SDL_SetWindowTitle(window: *Window, title: [*:0]const u8) bool;
```
```zig
// Export functions to external code with an 'acme_' prefix, but use non-prefixed names internally.
pub const add = acme_add;
export fn acme_add(a: c_int, b: c_int) c_int {
return a +% b;
}
pub const mul = acme_mul;
export fn acme_mul(a: c_int, b: c_int) c_int {
return a *% b;
}
```
### 1\. Remove `extern` and `export` decls in favor of the more powerful `@extern` and `@export` builtins
Problem:
- Having both `extern`/`export` decls and the `@extern`/`@export` builtins means that there are two different ways of doing the same things. `extern`/`export` decls are strictly less powerful and flexible than the `@extern`/`@export` builtins, only supporting the default linkage and visibility options.
If you start off by defining a bunch of `extern` functions and variables, and later realize that you need to change certain options, you will need to rewrite your `extern` decls into a form that uses the `@extern` builtin. This isn't difficult, but can be a bit annoying. Had you used `@extern` from the start, you would only need to trivially adjust the relevant `std.builtin.ExternOptions` fields.
- A slightly greater problem is that `extern` and `@extern` are not exactly equivalent. `extern var a: *T` is a special name binding syntax that will make the Zig variable `a` *an alias for* the external symbol *a*, and the expression `&a` will return the address of the symbol *a*. `const b = @extern(*T, .{ .name = "a" })` on the other hand will make the Zig variable `b` *a pointer to* the external symbol *a*, and the expression `&b` will return the address of the Zig variable `b`, **not** the the address of the symbol *a*.
In other words, `@extern` adds another level of indirection compared to `extern`. This can be a bit of a footgun. If you already have `extern var x: *i32`, and you need to rewrite it into the `@extern(**i32, .{ .name = "x" })` form, it is very easy to forget to add the second asterisk and end up being one whole level off. [Here is a Ziggit thread from a while back where a user was bitten by this.](https://ziggit.dev/t/global-assembly-and-how-to-use-an-extern-to-access-asm-label/11688) Similar case: [#19515](https://github.com/ziglang/zig/issues/19515).
- [The `extern "foo"` syntax is easy to get confused with C++'s `extern "C"`.](https://github.com/ziglang/zig/issues/19999#issuecomment-2119247194) This is obviously a relatively minor issue, but nevertheless it can be a stumbling block for beginners coming to Zig from C/C++.
To make the language surface slightly smaller and to disarm footguns, I propose that the `extern/export fn/const/var` syntax be removed, leaving the `@extern` builtin as the only way of declaring a reference to an external symbol and the `export` builtin as the only way of making a Zig declaration visible to external code. The `extern` keyword will still retain its use in `extern struct/union`, but the `export` keyword no longer has a use and will be deleted from the language.
With these changes, the original examples would need to be rewritten like this:
```zig
pub const getWindowTitle = @extern(
*const fn (window: *Window) callconv(.c) [*:0]const u8,
.{ .name = "SDL_GetWindowTitle", .library_name = "SDL3" },
);
pub const setWindowTitle = @extern(
*const fn (window: *Window, title: [*:0]const u8) callconv(.c) bool,
.{ .name = "SDL_SetWindowTitle", .library_name = "SDL3" },
);
```
```zig
pub fn add(a: c_int, b: c_int) callconv(.c) c_int {
return a +% b;
}
pub fn mul(a: c_int, b: c_int) callconv(.c) c_int {
return a *% b;
}
comptime {
@export(&add, .{ .name = "acme_add" });
@export(&mul, .{ .name = "acme_mul" });
}
```
Some important differences from before:
- The functions must now explicitly be declared with `callconv(.c)`. I would argue that explicitness is good here. Unless you have read the language spec carefully, it might not obvious that `extern` and `export` implicitly cause functions to get declared to use the `.c` calling convention (when discussing Zig with other users I have had to convince that `extern`/`export` work like this on more than one occasion).
- `set/getWindowTitle()` are now pointers to external symbols, not aliases for them. You can technically (sometimes?) dereference them at comptime with `@extern(...).*` and turn them into aliases; the exact behavior is a bit unclear to me (see also: [#21027](https://github.com/ziglang/zig/issues/21027)). Regardless, I think having them be pointers reflects their nature as non-Zig-native symbols better, especially if they are defined as weak or interposable and can be overridden by the linker/loader.
- For each decl, you now need to explicitly specify two names, one for the Zig decl and another for the external symbol. Once again I would argue that explicitness is good; here it helps reinforce to the user that there's a distinction between the Zig-native decl and the external symbol. Compare with other areas of the Zig toolchain that prefer explicitness over implicitness even if it can feel verbose/redundant to the user, such as package names <-> dependency names and module names <-> import names.
However, after looking at the above code you probably think that the `const foo = @extern(*const fn () callconv(.c) void, .{ .name = "foo" })` form looks a lot more noisy and awkward compared to the simple `extern fn foo() void` form. I'm inclined to agree! Which brings us to the next part...
### 2\. Replace the `@extern` type parameter with the inferred result type
In the recent years, the trend for builtins has been to favor result types over type parameters. Moving the type information from the first parameter to the result type immediately improves the situation by reducing visual clutter:
```zig
pub const getWindowTitle: *const fn (window: *Window) callconv(.c) [*:0]const u8 =
@extern(.{ .name = "SDL_GetWindowTitle", .library_name = "SDL3" });
pub const setWindowTitle: *const fn (window: *Window, title: [*:0]const u8) callconv(.c) bool =
@extern(.{ .name = "SDL_SetWindowTitle", .library_name = "SDL3" });
```
But can we do even better?
### 3\. Move the `name` fields of `std.builtin.Extern/ExportOptions` to the parameter list of `@extern` and `@export`
If you're referencing external symbols from a library, or if you're exporting your own symbols to external code, it's likely that you will want to use the same options for most/all symbols. However, annoyingly, the `name` fields of `std.builtin.Extern/ExportOptions` are used to specify the unique symbol name, which means that you can't define a common options instance and reuse it for all `@extern`/`@export` uses. Instead you will need to assign each field explicitly every time.
If we move the symbol name from the `name` field of the options struct to the `@extern`/`@export` parameter list, we can conveniently reuse common options and reduce visual clutter for the reader:
```zig
const SDL3: std.builtin.ExternOptions = .{ .library_name = "SDL3" };
pub const getWindowTitle: *const fn (window: *Window) callconv(.c) [*:0]const u8 =
@extern("SDL_GetWindowTitle", SDL3);
pub const setWindowTitle: *const fn (window: *Window, title: [*:0]const u8) callconv(.c) bool =
@extern("SDL_SetWindowTitle", SDL3);
```
```zig
pub fn add(a: c_int, b: c_int) callconv(.c) c_int {
return a +% b;
}
pub fn mul(a: c_int, b: c_int) callconv(.c) c_int {
return a *% b;
}
comptime {
// Setting some options to help illustrate the point.
const options: std.builtin.ExportOptions = .{ .linkage = .weak, .visibility = .protected };
@export(&add, "acme_add", options);
@export(&mul, "acme_mul", options);
}
```
Both builtins, `@extern` in particular, now look quite nice and ergonomic to use!
### 4\. Rename `@extern` and `@export` to `@externImport` and `@externExport`
This is a bit different from the above, but please hear me out.
If you didn't know anything about Zig and saw the names of the builtins `@import` and `@export` in isolation, you would probably naturally assume that they are related. After all, they form an antonym pair, and languages like JavaScript use `import` and `export` for what you would assume, and in MSVC-flavored C you have `__declspec(dllimport)` and `__declspec(dllexport)`. However, in actuality, `@import` and `@export` are used for two *very* different kinds of binding/linking: `@import` only deals with things within the same Zig compilation unit and `@export` deals with things from the outside world.
Similary, the `extern` and `export` keywords don't naturally appear to have anything to do with each other, when in actuality they are sort of the two sides of the same kind of linking. In particular, "this thing is defined elsewhere" is not the only logical conclusion for the meaning of the `extern` keyword and if you don't have prior familarity with the `extern` keyword from C it could just as well mean "make this thing available externally". (In fact I still frequently find myself not paying enough attention and typing `extern fn` instead of `export fn`.)
Therefore, I would like suggest renaming `@extern` and `@export` to `@externImport` and `@externExport` respectively, to make it clear and explicit to the user that
- the builtins deal with external code, not Zig code internal to the compilation unit, and
- which side (Zig or external code) provides the symbol.
In other contexts in Zig, the term "extern" means "compatible with the C ABI". `@externImport` for "receive something from the outside world" and `@externExport` for "provide something to the outside world" feels intuitive to me.
There might be other candidates for names that would work just as well; however, having the terms "import" and "export" in the names makes the builtins map nicely and intuitively to object formats like PE/COFF and Wasm which use separate tables for dynamic imports/exports.
Lastly, in case you're concerned that `@externImport` appears similar to but doesn't have much in common with how `@import` works, I'd like to remind you that `@import` makes use of result types when importing ZON files, e.g. `const config: Config = @import("config.zon")`, so they are not *as* dissimilar as they would have been a few versions back.
With all of the above changes, the final revision of the example becomes this:
```zig
const SDL3: std.builtin.externs.ImportOptions = .{ .library_name = "SDL3" };
pub const getWindowTitle: *const fn (window: *Window) callconv(.c) [*:0]const u8 =
@externImport("SDL_GetWindowTitle", SDL3);
pub const setWindowTitle: *const fn (window: *Window, title: [*:0]const u8) callconv(.c) bool =
@externImport("SDL_SetWindowTitle", SDL3);
```
```zig
pub fn add(a: c_int, b: c_int) callconv(.c) c_int {
return a +% b;
}
pub fn mul(a: c_int, b: c_int) callconv(.c) c_int {
return a *% b;
}
comptime {
const options: std.builtin.externs.ExportOptions = .{ .linkage = .weak, .visibility = .protected };
@externExport(&add, "acme_add", options);
@externExport(&mul, "acme_mul", options);
}
```
(I chose to rename the `std.builtin` types to `std.builtin.externs.Import/ExportOptions` per the [Avoid Redundant Names in Fully-Qualified Namespaces](https://ziglang.org/documentation/master/#Avoid-Redundant-Names-in-Fully-Qualified-Namespaces) advice, but this is only a suggestion.)
### Other thoughts
This proposal would eliminate the need for the suggested `linkname("foo")` and `linklibrary("bar")` proposed in [#19999](https://github.com/ziglang/zig/issues/19999) and [#20006](https://github.com/ziglang/zig/issues/20006) respectively.
It would probably be possible to have `zig fmt` automatically rewrite most uses of `extern fn` in the wild into the corresponding `@externImport`/`@externExport` variants without causing too much breakage, since function pointers can be (need to be) called without explicitly dereferencing. `extern const/var` is a bit more prone to breakage because the decls would now be pointers instead of aliases, but external variables are much less common than external functions. `export fn/const/var` should be fairly simple to automatically rewrite into a `comptime` block with an `@externExport` statement.