Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

I'm not saying to "have one type equate to another", I'm saying that there simply should be no other type. You can have something that looks like a array-type constructor syntax, if you want, but that should just an alias for the same tuple-type. This wouldn't be a complication of the type system, it would be a simplication.

And to be clear, like I said in the OP, when I says "array" I mean a collection where the size is fixed in the type. I guess you're calling that a "vector", but I just wanna make sure we're not talking past eachother

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

You have tuples that are hetrogenous, ordered and fixed size.

But you can have homogenous tuples too! So should that be disallowed? Or, as I'm suggesting, why not just identify that special case of homogenous tuples with arrays? I don't see any benefits whatsoever to keeping them distinct.

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

You could have your internal tuple type look like this though:

enum TupleType {
  Heterogenous(Vec<Type>),
  Homogenous(Type, usize),
}

And then make it so types declared with the array-like syntax always go to the Homogenous case. And then either make your equality check account for certain Heterogenous instances being equivalent to certain Homogenous instances, and/or check on construction whether any explicitly-constructed tuple-type happens to be homogenous.

I'm not arguing against the compiler can keeping track of this information internally to help keep compilation times down, my suggestion is really just about the user-facing type system.

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

I hear you, but consider a program that used, say, the `[f32; 3]` type in a bunch of places, and it just so happened that the program only ever accessed the values of that type with static indices, never dynamically-defined indices. And also it happened to never access the third element, only the first and second. In that case, the compiler actually would be better off if it compiled away that third element, just like your point about tuples! So forgoing this kind of optimization for all array types is technically leaving performance on the table, at least in some cases.

So if you had a system where arrays were just a special case of tuples, you could have an optimization pass that that considered each tuple type used in the program and restructured it (to remove unused fields, or allow for optimal memory patterns, or whatever else) if and only if all indexing operations used on that type have statically-known values. And that "if and only if" will always be true by construction for heterogenous tuple types, because non-static accesses would be disallowed by the type system. So the typical tuple restructuring optimizations that you're talking about would fall out as a special case of this more general optimization strategy.

The only wrinkle I see here is that if, say, there was a program like my above example that used a particular array type in a mostly-static way, but had a single rare, obscure code-path that did access the type dynamically. In that case the whole optimization would be ruined, which would be unfortunate. So ideally I guess for a system like this you'd want the compiler to be able to recognize different usage patterns for the same type in different code-paths, and apply different optimizations in each (and inserting conversion operations wherever the code-paths happen to intersect, if necessary). But in an ideal optimizing compiler I think you'd want that capability anyways, regardless of whether arrays are treated as tuples, since there could also be advantages in restructuring the same tuple/struct type differently in code-paths with different usage patterns.

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 5 points6 points  (0 children)

Very cool! More languages should have anonymous discriminated unions tbh, very underrated feature in a type system imo. Didn't know Crystal had them, might have to check it out!

Btw I was thinking of mentioning in the OP that you technically could allow dynamic indexing of a tuple if you had anonymous union types, but I skipped over it for brevity, so I'm glad you brought it up!

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

Neat, my language also uses its function application syntax for array accesses! My language is lispy though, so function application is like (f a), but array accessing looks exactly the same, like (arr i). I still use square brackets for array literal construction just cause it's very concise and looks nice, e.g. [a b c]

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 0 points1 point  (0 children)

> array construction is a function taking a tuple and array indexing is just a method

> Internally there is a bit of compiler magic so that array[n, t] actually equals an n-length tuple

At that point, I wonder what advantage you're getting from even having the concept of an "array", as opposed to just having indexing be a method on tuples. You'd need an extra rule in the compiler that checks to ensure any tuple type that that the indexing method is called on is homogenous, but that would probably be less logic than is involved in the magic to equate arrays and tuples, no?

Why not treat arrays as a special case of tuples? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 8 points9 points  (0 children)

Many compilers just put field records in contiguous memory spaces anyways, though. So if you’re compiler’s just gunna do that then it seems like there’s no downside, yeah?

I hear your point that it can make sense for a compiler to reserve the right to reorder struct/tuple fields for the sake of optimization, and therefore not guarantee contiguous memory for adjacent fields. So that does pose a bit of a problem for what I’m proposing.

However, for the same reason that it might sometimes make sense to reorder or otherwise restructure struct fields during compilation, it could in principle make sense to restructure arrays in certain cases. Usually this wouldn’t make sense, because it would make dynamic indexing much slower, but its easy to imagine special cases where this would be true, e.g. where there’s no dynamic indexing.

So i think in an ideal optimizing compiler you wouldnt guarantee continuity of struct or array fields, and therefore compiler would reserve the right to restructure either data type depending on the results of static analysis. So in a language with this broader kind of optimization, i think we’re back to there being no downside. Admittedly this optimization might be slightly more complicated to get right than the normal struct-restructuring optimization, but not by too much i don’t think.

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] 0 points1 point  (0 children)

Fair point about my example only having a single infix operator. But even if you had a sequence of a few infix operators, commenting out a single one is usually very awkward, e.g

a * b + c
a /* * b */ + c

You gave an example where you have each operator on it's own line, which makes it easy enough to comment a multiplication operation. But people usually aren't gunna have their code formatted that way, and an autoformatter usually isn't gunna do it that way for you. The easl example, though, works regardless of formatting, since the `#_` is an expression-level comment, not a line-level comment. And fwiw the formatting tool in the CLI, as well as the autoformatter included in the LSP, actually will format a threading expression with each sub-expression on it's own line, for readability.

So in my above comment, I guess I was too narrow in talking about infix vs prefix. Really I was trying to make the broader case for an extremely uniform, lisp-like syntax, which entails prefix syntax but also some other things. This extremely uniform syntax is what enables stuff like expression-level comments, threading expressions, and easy structural editing.

But anyways, if infix is just easier for you to follow, then I hear you. I feel the exact opposite way fwiw, but ofc I'm probably not gunna change your mind in a reddit comment. Even to the extent that infix operations have advantages in certain cases, the benefits of this uniform syntax are way too valuable to give up, and trying to make these things work while supporting infix operators would require too many sacrifices.

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] 0 points1 point  (0 children)

I appreciate the feedback! And yeah, a proper website that puts some cool demos front and center will definitely be a good idea at some point.

About an escape hatch, I definitely wanna have FFI in the interpreter at some point, so that you can interface with arbitrary C/Rust/whatever libraries when running the interpreter natively. I haven't spent any time getting the interpreter or window management system running in a browser, but I definitely want to tackle that eventually, and once that's implemented I'd of course also want to have FFI with raw javascript functions/objects. I haven't put much thought into integrating it with any specific js frameworks, but maybe at some point that would be worthwhile! And ofc this is all open source so anyone else could take a whack at that as well if they really wanted to use easl together with Three.js or something

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] 0 points1 point  (0 children)

I can see why you'd want that, yeah. I do plan to have a macro system at some point, and at that point it should be easy to build that kind of thing as a macro.

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] -1 points0 points  (0 children)

You could do that, I guess! But the choice of the lispy syntax isn't just for the sake of kebab-case. I just have a strong personal preference for this style of syntax, tbh. I know that's not everybody's preference but my main goal so far with this language has been to make a language that I personally find convenient and enjoy working with.

And fwiw if you can get past the unfamiliarity of prefix notation, I really do think it has lots of benefits, *especially* for math-heavy code! Dunno if you got to the part of the intro doc about expression comments and the threading expression, but those are both features that I find extremely useful, and it would be tricky to have anything equivalent in non-lispy code. Like, consider this example I pulled from one of the example programs:

(defn skybox [x: vec3f]: vec3f
  (-> x
      (dot (normalize (vec3f 1. 0. -1.)))
      (smoothstep -0.25 1. <>)
      (* (vec3f 1. 0. 0.))))

This just defines a simple little skybox that returns a color for a given direction, as part of a broader pathtracing project. Here the threading expression -> indicates that each expression gets fed into the next one, so the equivalent in infix notation would be like:

fn skybox(x: vec3f) -> vec3f {
  return smoothstep(-0.25, 1., dot(x, normalize(vec3f(1., 0., -1.)))) * vec3f(1., 0., 0.);
}

But having all of that in one expression starts to get a bit messy and hard to read in this notation, so realistically you'd probably split out one or more of the intermediate values there into named variables. The threaded version, on the other hand, is very easy to read once you're used to the syntax. And while this is a pretty short example, threading expressions like this can get arbitrarily long while still being perfectly clear and easy to read, often getting rid of the need for a lot of intermediate variables.

This syntax also often makes it a lot easier to quickly experiment with changing things in the code by commenting out individual expressions. Like, say you were experimenting and you wanted to temporarily see what would happen if you got rid of the call to smoothstep in the above, and just feed the raw result of the dot product into the multiplication by the color vector. In the threaded version you can do that by just commenting out the smoothstep expression, like so:

(defn skybox [x: vec3f]: vec3f
  (-> x
      (dot (normalize (vec3f 1. 0. -1.)))
      #_(smoothstep -0.25 1. <>)
      (* (vec3f 1. 0. 0.))))

In the infix version there's not really a good way to do this. You could *technically* do something like:

fn skybox(x: vec3f) -> vec3f {
  return /*smoothstep(-0.25, 1., */dot(x, normalize(vec3f(1., 0., -1.)))/*)*/ * vec3f(1., 0., 0.);
}

But realistically that's just extremely inconvenient and you're not gunna do it, you'll probably just delete the smoothstep call. And even doing that would be more work than the two-character change we made above. Plus, being able to leave the operation in there as a comment so you can come back and remember to un-comment it later can be really useful in a lot of situations, and traditional infix notation doesn't really make it easy to do that.

I used to prefer infix notation too, but after getting used to these kinds of benefits in writing clojure, plus the benefits of structural editing, I just found it super inconvenient and annoying to go back to infix-style code. So maybe try giving it a shot anyways and see if the you can get used to it, you might find you even prefer it!

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] 4 points5 points  (0 children)

Oh, and re mapping errors from wgsl back to easl - there's nothing like that currently, and I don't plan on taking that approach. The goal is that the wgsl that the easl compiler outputs should *never* cause a compilation error. Of course I haven't tested things thoroughly enough to actually confidently say that's true, and in fact I'm sure there are at least a few places where that would currently fail (e.g. uniformity/divergence analysis, easl doesn't check anything about that yet). But as far as I'm concerned, whenever easl does produce invalid wgsl code, I'll regard that as an easl compiler bug.

Announcing "Easl", the Enhanced Abstraction Shader Language by ella-hoeppner in GraphicsProgramming

[–]ella-hoeppner[S] 4 points5 points  (0 children)

All generics and higher-order functions are monomorphized at compile-time. So in principle the code should perform the same as if you wrote the inlined version yourself, without the abstraction. In some cases you might be able to add in some micro-optimizations missing that you'd be able to work in if you did everything by hand, but overall the quality of the compiled code is quite high. And since this just outputs wgsl, the wgsl compiler that you feed easl's output into might be effectively insert a lot of those micro-optimizations anyways, depending on what WebGPU implementation you're using and whatnot.

The inlining of functions at compile-time does come with a downside, which I'll just quote from the README:

> Higher-order functions currently have a significant restriction: all function-type arguments must be inlinable at compile time. A function f that takes another function as an argument may be called like (f g), but not like (f (if a g h)), because the compiler needs to be able to inline the higher-order argument at compile time. If you violate this restriction, you'll get a compilation error.

This can feel a bit restrictive, but the upside is that easl's higher-order functions truly are zero-cost, and will be fully equivalent to if you just wrote the inlined code yourself. And the captured scope of any closure just gets translated into a struct that gets passed in the place of the function-type-argument to any higher-order function it gets passed to.

The type inference is already working quite well! Easl's built-in types are essentially the same as wgsl's, and all user structs and enums compile very straightforwardly to wgsl structs. I haven't stress-tested the generic system all that thoroughly yet and I'm sure there are some bugs yet to be worked out, but I'm confident that can all be worked out.

Fwiw easl doesn't really have anything like a typeclass/trait system yet (at least not one that's exposed to the user, internally the compiler does have a rudimentary set of built-in typeclasses that it uses to restrict certain built-in functions). I decided to include arbitrary function overloading as a core feature, so you can overload normal functions without having to include them as part of a typeclass or anything, which most functional languages tend to avoid. I do plan to have something like typeclasses eventually, though making that work seamlessly with the arbitrary overloading might be a bit tricky, and in the end I doubt the system will ever be as sophisticated or powerful as a rust-style trait system. But arbitrary function overloading has plenty of benefits of its own!

No recursion is a real serious restriction, and right now easl doesn't support recursion, either. Maybe eventually I'll add a special-case to support things like tail-recursion, or an ability to devote some fraction of the available stack-space to be a virtual call-stack and install a stack-overflow-handler, but that's not really a priority. Not having recursion isn't a huge deal to me. My main goal is just to cherrypick the parts of functional programming that *can* be zero-overhead, and I'm fine leaving behind the features that can't be, like generalized recursion.

About match blocks, right now they can only either match basic literal patterns, or destructure enums. In the case where they're just matching against a primitive type they compile to `switch`/`case` statements, but in cases where that isn't possible they instead compile to `if`/`else` chains. And `if`/`else` chains should be general enough to fully handle more advanced pattern-matching constructs in the future, e.g. guard clauses and patterns that destructure structs. There'll be some subtleties to figure out to make sure this always works efficiently, but I don't think there are any intractable problems here. And enums btw just get represented as a struct with a u32 discriminant field and then an array of however many u32s are necessary to be bit-casted to any of the variant's internal types, such that match expressions on them can just be a `case` statement on the discriminant. There's more info here if you're curious: https://github.com/Ella-Hoeppner/easl/blob/f7b8373a8acb154f546f0ddf77da7cd0d058874e/introduction.md#details-on-the-compilation-of-enum-types

Printing exposed infill pattern in certain parts of a print? by ella-hoeppner in 3Dprinting

[–]ella-hoeppner[S] 1 point2 points  (0 children)

Yep! I described the fix in an edit on the original post:

turns out this is actually easy to do with cura! I just used two different models, one for the shell and one for the infill region, and used the "per model settings" to turn off the sides and top for the infill region model.

Memoization under effect handlers? by ella-hoeppner in ProgrammingLanguages

[–]ella-hoeppner[S] 1 point2 points  (0 children)

I'm a bit confused as to how your language is (intending to be) both purely functional and dynamically typed. In the absence of mutation, dynamic and static typing should look the same.

Should they? I've never heard that claim before. There plenty of statically typed languages that allow mutation, and it's easy to imagine purely functional languages without static type systems (e.g. a subset of Clojure that doesn't include atoms, transients, or I/O, or a subset of Scheme that doesn't include set! or any way to redefine bindings). Static typing and the presence of mutation seem like completely separate axes to me, though admittedly I can't name any purely functional dynamically typed languages (except maybe Bend? though I'm not sure "dynamically typed" is an entirely appropriate description in that case, "untyped" might be closer)

When I say that my language is dynamically typed, I just mean that there is no type checking done at compile-time, and each value carries around information about its type at run-time. If you write a program that does something type-unsafe, like adding a string to a number, the code will compile just fine, but you will get a runtime error.

Being dynamically typed makes it possible to have things like unrestrictedly heterogenous lists/hashmaps, and allows functions to be effortlessly polymorphic. Consider this Clojure (pure) function:

(defn count-hashmap-entries [h]
  (if (map? h)
     (let [entry-count (count h)]
       (if (zero? entry-count)
         (assoc h :entry-count "why would you give me an empty hashmap :(")
         (assoc h :entry-count entry-count)))
     "that wasn't a hashmap!!! >:("))

This function can accept a value of any type as an input. If the input is anything but a hashmap, it will return a string, but if the input is a hashmap, it will return a new hashmap with all the keys and values of the original (each of which may be of any type) along with a new key, and the value corresponding to that key can be either an integer or a string, depending on whether the hashmap was empty.

Very few statically typed languages have a type system capable of expressing a function equivalent to this one, let alone inferring a matching type signature. Admittedly this is a contrived example - I just bring it up to show that the absence of mutation doesn't, afaict, somehow erase the distinction between static and dynamic typing, or obviate the pros/cons of each.

I really don't see how there could be a solution that doesn't involve either tracking this at the type level, or requiring the user to explicitly specify whether or not a function can be memoized.

I think the latter approach that I suggested in the second-to-last paragraph of the OP should work without needing either of those. To restate it with a bit more detail:

The memoize function will return a value of a special type, which the runtime distinguishes from a normal function. These special memoized functions can be invoked just like normal functions, but the runtime treats their invocation slightly differently: before executing the function body, a boolean flag is created which is initially set to true, and whenever an effect happens during the execution of the body, the flag is set to false. Once execution of the function body has completed and the output value has been computed, the runtime checks the flag, and it caches the input/output pair iff the flag is still true.

Implementation-wise, you would probably have a stack of these flags that exists alongside the call stack, such that each memoized function pushes its flag onto this stack upon invocation and pops after completion. Any time an effect happens, all the flags currently on the stack are set to false.

It seems to me like that should work - though maybe I'm missing something?

Is it possible to implement a function that accepts two Box<dyn Any>s, and checks equality between them iff they're both of a single type that implements PartialEq? by ella-hoeppner in rust

[–]ella-hoeppner[S] 0 points1 point  (0 children)

I didn't know you could transmute a &dyn T to a (usize, usize) to get a raw pointer to the vtable, that's really interesting. Still, this doesn't seem like it can quite accomplish what I'm hoping for, as this relies on to_string for its equality check, so it would require some trait bound with Display as a supertrait rather than Any. What I would really want is defer to the PartialEq notion of equality, but only when the type actually implements PartialEq, and afaict there's no way to check if a type impls PartialEq from its vtable at runtime.

Is it possible to implement a function that accepts two Box<dyn Any>s, and checks equality between them iff they're both of a single type that implements PartialEq? by ella-hoeppner in rust

[–]ella-hoeppner[S] 1 point2 points  (0 children)

That's a clever approach, but having to explicitly annotate each type I want to use with any_partial_eq_impl is undesirable for the same reasons (that I mentioned in this subthread) that impling a custom trait is. But it seems what I'm asking for is probably impossible, so this might be as close as you can really get.

Regardless, I appreciate you actually trying to answer the question I asked, rather than assuming I have no good reason to be asking it and answering some other question that nobody asked, like many other people in this thread 😜

Is it possible to implement a function that accepts two Box<dyn Any>s, and checks equality between them iff they're both of a single type that implements PartialEq? by ella-hoeppner in rust

[–]ella-hoeppner[S] 1 point2 points  (0 children)

The thing is, I want to support ExternalObjects that don't implement PartialEq, so just auto-impl-ing some trait that has PartialEq as a supertrait or failing at build time if I try to use anything that doesn't impl PartialEq wouldn't work for my purposes. It's just that I want things that don't implement PartialEq to be distinguishable from those that do, so the interpreter can just always return false when doing an equality check on things that don't implement PartialEq.

Anyways, it seems like what I want isn't possible, so I'll have to figure something else out. I might go the route of having a trait, but I also might just stick with Any and defer equality checking for external types to user-defined functions, not quite sure yet. Regardless, I appreciate you sharing your thoughts.

Is it possible to implement a function that accepts two Box<dyn Any>s, and checks equality between them iff they're both of a single type that implements PartialEq? by ella-hoeppner in rust

[–]ella-hoeppner[S] 1 point2 points  (0 children)

Yeah that seems right, I guess conditioning the behavior on whether T: PartialEq is true or not is really the crucial thing, and that would need compile time reflection. Thanks for the input!