visibility design and discussion#187
Conversation
|
This looks very solid to me and would definitely bring a big usability gain to libraries like lygia. The one thing that doesn't seem be discussed in much detail is
It seems like the docs here don't explain why this approach is chosen over an
Personally I don't have a strong opinion either way, but there is lots of prior art for the second option (most JS package systems, Python, Rust, Lua) while I can't think of anything that has this strict root/non-root separation off the top of my head. |
|
I believe that we should be able to dig up the Rust recommendations for that. IIRC, Rust discourages the For example, if I have 3 tabs open in VSCode and they all say |
|
Also quick and very inconvenient note: |
|
Without wildcards, resolving an import means going to the right module and finding the item. With wildcards, resolving an import means looking through a set of modules to find the right item. With wildcard re-exports, resolving an import means looking through a set of modules recursively to find the right item. And since modules can have cycles, one now needs to keep track of which modules have been visited during import resolution. In the language server, this is a fair amount of extra complexity. This particular feature also, I believe, interacts really badly with macros or other compile time code generation. Rust's import system ends up being a very complex fixed point finding algorithm which may sometimes simply fail. (I think it also interacts with parameterized modules. Since wildcard reexports is the missing piece to make import statements recursive. If imports can cause any sort of computations, which compile time generated code does, and which parameterized modules do, then one gets the halting problem. Or so the story goes.) Usually I wouldn't object to complexity too much. |
Not sure, always interesting to look around tho! Here I was only filling the gap where our current scheme doesn't allow something at the root level. I think small libraries especially might like to enable their users to It'd be a separate issue, but your comments about consistency... I suppose we could revise the existing rules so that |
wat!? good catch. Perhaps |
Worth thinking about. I like the idea of implementing the needed features w/o gilding the lily too much. The cycles/complexity concerns seem heavy for a feature that isn't must-have. And foreclosing options for parameterized modules would be unfortunate. Also I realize that the PR doesn't address some related issues:
I'm thinking best would be to pull wildcard re-exports from this PR. Side note, if we go with the publish tool route as planned with #183, I suppose we could also expand wildcard re-exports like we're planning for wildcard imports. That would help for the cross package cases. |
|
pushed a draft
|
|
We could also go with
Also, what does private mean? Rust goes all in on the hierarchical module system and says
|
I like this option too, a bit more cryptic but if perhaps best if we prefer keywords to attributes.
I like I suppose we could do
just the module, no hierarchy. we could expand hierarchically later, it's a nice idea. But I think of most WESL programs as smallish, so perhaps unnecessary complexity, better to defer. |
|
For the
|
Ah, that was my first instinct too. I like modularity. But I've come to think that package by default for WESL is better. See |
|
Also interesting tidbit: The other style is the global style, where I use 3 access modifiers. Public, internal (protected) and private. And apparently some of the very prolific Rust contributors, including epage who maintains a bunch of extremely well written libraries, vastly prefer the global style. So we are on the right track! https://www.reddit.com/r/rust/comments/1k5yv6p/two_ways_of_interpreting_visibility_in_rust/ |
|
More from Rust land:
They also include statistics which says that |
|
And from Kotlin lang: They went with public by default, only to realize that it might have been a mistake. So defaulting to a more restrictive choice like "internal" seems wise |
|
Note from Java: |
|
We really should ask the WGSL committee regarding the |
|
I dug into it and asked the WebGPU commitee gpuweb/gpuweb#6264 I couldn't quite figure out where it got lost though. It seems to have happened quite a while ago, judging from Naga's source code |
|
I talked to a friend regarding this. |
|
And Jim Blandy pointed out that Rust doesn't have a private keyword. |
|
I asked the Slang team! |
| 2. We take that as the "current module". | ||
| 3. We repeatedly look at the next segment. | ||
| 1. Item in current module: Take that item. We must be at the last segment, otherwise it's an error. | ||
| 1. Item in current module (declared or re-exported via `public import`): Take that item. We must be at the last segment, otherwise it's an error. |
There was a problem hiding this comment.
Can public import also re-export modules?
There was a problem hiding this comment.
could be a future thing, perhaps..
There was a problem hiding this comment.
We can do it as a future thing, but we might need this for Bevy
|
|
||
| To get an absolute path to a module, one follows the algorithm above. In step 1, one takes the known absolute path of the `super` module, or the package. | ||
| The absolute path of the `super` module is always known, since the first loaded WESL file must always be the root module, and children are only discovered from there. | ||
| The steps above resolve a path; whether the referencing module may then use the result is governed by [visibility](Visibility.md). |
There was a problem hiding this comment.
How does this interact with wildcards?
Is this a name clash?
import foo::*;
import bar::*;
const B = A;
when
// foo.wesl
private const A = 3;
// bar.wesl
public const A = 3;
Or is visibility more intertwined with name resolution?
There was a problem hiding this comment.
good question, I think it should be only be a clash if both are visible.
| [WGSL recursive descent grammar](https://www.w3.org/TR/WGSL/#grammar-recursive-descent) | ||
| for WGSL's full attribute production. | ||
|
|
||
| ### Referring to less-visible declarations |
There was a problem hiding this comment.
Is there a reason why this should be allowed at all?
There was a problem hiding this comment.
Authors can write a fn that makes and returns a struct with private visibility. API users can pass the struct back to another fn, but can't build one them themselves w/o going through the API. That might be nice for the author, maybe they want to track every struct built in a table or something and don't want users to be able to able to cheat around their intended makeThing() api.
Requiring that every struct mentioned in a public fn also be public is reasonable, but less flexible for API authors. Other languages undoubtedly differ on this. I was just scanning through Rust RFC 2145 which makes it sound like Rust had a strict requirement and then changed to warn/lint. Go allows it but has a lint. TypeScript allows it I think.
There was a problem hiding this comment.
Couldn't one achieve the same result by making one of the struct fields private? Then the struct is also not constructible for the API users.
I don't fully understand why Rust relaxed their rules, from the sounds of it they started out with a suboptimal version of "no private in public".
There was a problem hiding this comment.
Couldn't one achieve the same result by making one of the struct fields private? Then the struct is also not constructible for the API users.
Struct field visibility is interesting, but perhaps mostly orthogonal.
I agree we could block consumer construction of structs w/ private fields. Foo(1,2,3) wouldn't work outside the module if one of Foo's fields was private. But that workaround only matters now if we go strict on the leaked-type rule, and going strict cascades through every transitive field reference. Sounds like Rust wanted to relax that in RFC 2145, so not sure we'd go that way.
i.e. if we eventually add private struct fields, it's plausible, maybe even likely that we'd still want to allow public structs to have fields of private type. In that case, private fields would give a new way to have private struct types: named-but-not-constructable in addition to the current unnamed-not-constructable.
Either way, I'd suggest we defer til we decide to do field level visibility.
| name the less-visible type to declare a variable, import it, or construct a new | ||
| value. | ||
|
|
||
| WESL publishing tools should warn when an `@public` declaration mentions a |
There was a problem hiding this comment.
What about other WESL tools like the language server?
There was a problem hiding this comment.
good point, other tools like the lang server should warn too.
| as MeshA;` alongside `... as MeshB;`) just adds reachable names; all of them | ||
| resolve to the single declaration. | ||
|
|
||
| ### Only public items can be re-exported |
There was a problem hiding this comment.
Why restrict re-exporting to @public? I find it quite useful within a package as well, once the package crosses a certain size threshold.
There was a problem hiding this comment.
simplicity, could add later I agree
There was a problem hiding this comment.
I think it'd be simpler to implement if we have the internal variant as well. The way it's currently phrased with @public means that I need to add quite specific checks for the restrictions.
(This is very speculative of course. My point is mostly that I think we can safely allow more here, without adding notable implementation complexity.)
There was a problem hiding this comment.
It's a good question, I went back and forth on this as I was drafting. What's simpler for the user? I agree it's a little odd to have to declare an app's own fragment shader as public just so that it can be pulled into the root module. On the other hand it's another kind of re-exporting for users to learn about, which adds complexity and brings up questions about internal re-exporting in general (two paths to a declaration, module path vs declaration visibility, etc.) that I'd rather keep out of scope for this PR.
I'd prefer to keep re-export to public import only for this if possible. We can revisit and extend to package import if need be in a subsequent PR.
| relaxation would let the root module `@public import` *package* items from the | ||
| same package, since the root defines the package's external and pipeline-visible | ||
| API. The cost is loss of orthogonality: what `@public import` accepts would | ||
| depend on whether the importer is the root. |
There was a problem hiding this comment.
Ah, so basically I cannot do public import package::some_plain_wgsl_file::fragment_shader;. Because the fragment_shader in the plain WGSL file isn't public.
There was a problem hiding this comment.
But I should be able to do internal import package::some_plain_wgsl_file::fragment_shader;. After all, package-level items are pipeline-visible.
There was a problem hiding this comment.
agree that would be nicely orthogonal, but prefer to defer to keep this PR constrained.
| mark those items explicitly, even authors who don't otherwise care about | ||
| encapsulation. Package as the default makes encapsulation discipline available | ||
| to projects that want it (via `@private`), without forcing typical small | ||
| projects to do the extra work. |
There was a problem hiding this comment.
I don't think this argument holds up. Pretty much every host language (C, C++, Rust, Typescript, ...) already makes the authors do this work. And my the dream shading language would be whatever host language I use.
So using the same patterns across my codebase is less mental overhead for me. Instead of having to remember "wait, well written WESL means that I have to write private more often"
There was a problem hiding this comment.
It's a good challenge, this argument may not land for many. Presuming we end up going with package by default, I'll reorder this section to lead with the structural arguments and de-emphasize this point.
The host-language story is mixed (e.g. Java, Scala, Kotlin, Swift are not private by default, so it's not a clean vote), but for users coming from TS or Rust you're right that this argument doesn't carry much weight on its own.
| to projects that want it (via `@private`), without forcing typical small | ||
| projects to do the extra work. | ||
|
|
||
| * **WGSL files can be imported without modification.** WGSL has no visibility |
There was a problem hiding this comment.
I sort of agree with this one. It should be possible to incrementally adopt wesl.
But at the same time, I'd also like to note that that existing WGSL code has no imports and is not always designed for it.
If I were to add visibility to WGSL itself, I would likely choose an option where one has to add visibility markers before being able to use the code in other files. This is analoguous to what Javascript did with the export keyword.
In fact, as an imaginary WGSL (not WESL) standard writer, I would argue that allowing arbitrary imports is a footgun. Existing WGSL code has been written without a module system in mind. So there are no encapsulation boundaries anywhere. I would start accessing all the right and all the wrong things, without the language steering me in the correct direction.
With my IDE hat on, I would agree. If I add "allow imports from anywhere" into any of my existing WGSL codebases, I'd get a hundred silly recommendations. Often I'd get 5 separate recommendations for the same type, because I copied it 5 times. And worse yet, of those 5 times, 2 of them would be subtly different, due to reasons like gpuweb/gpuweb#4574
There was a problem hiding this comment.
I suppose the IDE suggestions perspective might cut both ways. If I copy in some useful WGSL code from an old project or the web, then I'd like to use the IDE auto complete to explore the WGSL. But if I leave the WGSL code in for a while, then I agree I'd prefer to just have the one routine I know I need suggested but can't quite remember how to spell and not the other ought-to-be-private declarations. Autocomplete is probably not the deciding factor regardless; the incremental adoption is more important, as you note.
| principle, and shader source loaded as a string (without a file extension) | ||
| would still need some other way to mark the dialect. | ||
|
|
||
| * **Root module entry points would need explicit markers.** A root module's |
There was a problem hiding this comment.
This is a solid argument. So "private by default" with a file-level private would immediately break all WGSL files.
Which means that the WGSL standards commitee could not choose this option without introducing a breaking change to WGSL.
In terms of other options:
- For the entrypoints and bindings, there is the option of saying "
@fragmentimpliespublic" and "@groupimpliespublic". But that feels weirdly inconsistent with the rest of the language. And this rule fails to cover overrides. - Root modules with a different behaviour seem weird. Having a consistent language means fewer things that I have to remember.
privatecould simply not affect pipeline visibility. But then we break encapsulation in a weird way.privatecould be much less strict, similar to Rust. Rust made the observation that file level private is often still too strict.
There was a problem hiding this comment.
privatecould be much less strict
We could use package by default, and have private cover both the module and its descendant modules. So a private fn foo declaration in module shaders/bar.wesl would be visible also to shaders/bar/zap.wesl.
Seems reasonable and doesn't conflict with the current default or the import path model. The locality cost for the reader is mild, they'd have to consider their relative place in the module tree, where today they just have to consider the declaration itself. It's additive, we can add it later without breaking anything. Prefer to defer until we know we want this approach vs. other potential mechanisms for bigger projects.
There was a problem hiding this comment.
other options:
(suggested on discord) Package visibility by default for items, but a submodule is path-private: not reachable to other modules unless its parent forwards it. The forwarding mechanism is re-exports syntax applied to module paths, rather than just items as in the current PR. So a tests/ directory whose parent doesn't forward it is unreachable from non-test code in the same package.
But:
- Today an item's visibility is clear from its declaration. Path-private modules mean you'd have to read ancestor files to know what the parent forwarded. Visibility becomes non-local
- A main advantage of package by default is that small projects with a few files don't need to care about visibility rules. This would be effectively private by default. We'd lose the advantages of package by default.
I agree we'll want a solution for tests/, but #160 sketches an alternate approach based on wesl.toml, so we've at least one alternate way forward.
Path visibility might be worth a separate PR if #160 doesn't work out. Either way I think we'd still want declaration level private for the routine within-module case. Path visibility wouldn't replace it.
There was a problem hiding this comment.
Today an item's visibility is clear from its declaration. Path-private modules mean you'd have to read ancestor files to know what the parent forwarded. Visibility becomes non-local
There are semver relevant cases where making a module private is important. Main one being module re-exports.
If I write a little library that does the following
public bevy_pbr::internal_module as bar;
Then Bevy's internal_module is suddenly part of the public API of my library users. Aka renaming it becomes a breaking change for Bevy, despite the intent very much being "that module should not be public".
Therefore, we do need a way of saying "this module is private".
Which yes, makes visibility less local.
We could also get there with an explicit rule of "a module's visibility is the maximum visibility of all its items". As in, if I only have internal items, the entire module is internal.
There was a problem hiding this comment.
This would be effectively private by default
It is significantly less strong than private by default.
With a strong interpretation of private by default, I have to add a marker to each item.
// my_module.wesl
public const a = 3;
public const b = 5;
With "sub modules are private by default", I only have to add a marker to the parent module's import statement.
// package.wesl
public import package::my_module;
There was a problem hiding this comment.
So "private by default" with a file-level private would immediately break all WGSL files
-- @stefnotch
Well, looking at Javascript's history, this isn't entirely correct either. JS introduced modules and required the callers to say "this is a module"
That said, WebGPU could do an equivalent thing.
const vertexShader = device.createShaderModule({
module: fancyShaderString,
// instead of the current
// code: shaderString
});
| toward package as the default: | ||
|
|
||
| * **Encapsulation gains are smaller at WESL scale.** A typical WESL project is a | ||
| handful of files: a vertex/fragment pair, a compute shader, perhaps a few |
There was a problem hiding this comment.
I'm not sure if I agree with the assumption here. I'm more familiar with game development, where a typical shader codebase is
- a few files that I wrote. These are indeed somewhat small. But they are also very standalone, due to the nature of things. In fact, while many big game engines (e.g. Unreal Engine) have "material functions", it is kind of rare for me to import code from another shader that I wrote.
- a massive amount of game engine shader code that I rely on. I import a few types from here and the rest is a black box
And I suspect that the same is true on the web, since frameworks like Babylon.js and Three.js are quite popular.
So targeting small shader projects that are not already a part of a game engine is, I think, targeting an overly small part of the userbase.
There was a problem hiding this comment.
Agreed that many people will use big frameworks when they use webgpu. And the authors of big projects still have all the capabilities they need, regardless of the defaults discussed here. Here it's more about syntax convenience for the users of those frameworks (and those not using frameworks). The point is that most people using WebGPU will be writing smallish things with smallish teams that aren't so important to carefully internally modularize.
revert from @public/@Private back to public/private `override` section describes observable semantics rather than prescribing convert to const. clarify that leaked_type warning applies to all WESL tools Broaden language around re-export rule in case we add package level re-exports in the future. drop `private` from the WESL hard-keyword list. (`private` is a context-sensitive keyword in WGSL, so it is a legal identifier in non-visibility positions.) clarify that wildcard imports clash only on visible items "Why not private by default?" reorder to lead with the structural arguments, mention other langauges, general cleanups. clarify static reference meaning => transitive ref from root module under current conditions
| transform it into a `const` if its default is a const-expression, or inline its | ||
| initializer at use sites. These strategies are observably equivalent to the | ||
| host. If the `override` doesn't have a default value, it is a link error if the | ||
| `override` is statically referenced from the root module. |
There was a problem hiding this comment.
I suppose one way of properly dealing with override would be to extend WESL to also cover a small bit of the WebGPU API. We'd extend the way createShaderModule works or something along those lines.
| ## Why re-exports cannot widen | ||
|
|
||
| A `@public import` cannot widen visibility: re-exporting a *package* or | ||
| `@private` item as `@public` is a hard error. |
There was a problem hiding this comment.
There is one amusing way of bypassing this restriction.
//- utils.wesl
struct InternalStuff { a: 32 }
//- package.wesl
import package::utils::InternalStuff;
public alias NowItIsPublic = InternalStuff;
There was a problem hiding this comment.
clever and amusing, indeed!
|
Should we see if we can get #183 over the finish line? I do have one alternative proposal for this and #183, which is based around making full use of import statements. Here I'm only listing the upsides. It would look like this @wildcardable
public import bevy::prelude;Upside: No new Sub-modules would be private by default. public import package::lights;
public import package::shadows;
internal import package::utils;Upside: Automodules would no longer be a feature. It adds a lot of complexity to the language server. (I also suspect that the current version of automodules is not implementable in browsers, since it relies on web servers returning a "not found" within a reasonable timeframe.)
@if(DESKTOP)
internal import package::desktop::raytraced_lights as lights;
@if(!DESKTOP)
internal import package::mobile::lights;and now
internal import package::vertex::vertex_main; |
| 2. We take that as the "current module". | ||
| 3. We repeatedly look at the next segment. | ||
| 1. Item in current module: Take that item. We must be at the last segment, otherwise it's an error. | ||
| 1. Item in current module (declared or re-exported via `public import`): Take that item. We must be at the last segment, otherwise it's an error. |
There was a problem hiding this comment.
New question: If it is an item in the current module, but the item is not accessible, what should happen?
- Throw an error. This always behaves consistently.
- Ignore it and continue on. Downside is that name resolution can now depend on where you're importing something from in a very funky way.
TLDR
public(visible to any package),private(declaring module only), and package (same package, the unmarkeddefault).
public importre-exports the imported names under thecurrent module's path;
public import path::*does the same for a wildcard(re-uses
@wildcardablediagnostics).API: entry points, resource variables, and pipeline-overridable constants
reach the host only if the root module declares them or
public imports them.package.wesl. The file backing a package's top-level module, so itsitems are reachable as
<package>::item(e.g.,import wgsl_test::*). Oneplace a library can put a re-export prelude.
Why these four things are covered:
import wgsl_test::*;(Note that VisibilityLanguages.md is discussion background, it'll be removed before merging.)
Would resolve:
(largely covered already; this PR fills in the re-export piece)
Related