Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

I know its a faux pas in the Java world to acknowledge the existence of .NET, but how does this differ from .NET structs?

Value types, generic specialization, boxing - a quick skim makes it looks like they picked the same choices.



C# actually has a fair amount of gotchas and Java aims to make these explicit. So where C# mostly copied C from a low level perspeCtive, the Java guys approached this high level and analyzed in detail which constraints give you what kind of benefit.

So where in other languages, the struct/class taxonomy is binary, Java allows more granular control, reflection the semantics of the underlying domain. Snd as it turns out, structs have a wide range of footguns, especially in a parallel context.


Could you actually explain/exemplify any of the gotchas and what's been made better (or is this just handwaving)?


Part of the reason Java hasn't reified generics is because C# did and it was a real big headache that also limited non-C# languages on the C# runtime (CLI?). Everything had to be recompiled to work with newer C# runtimes. While it's pretty easy to run a bunch of language on the JVM (Javascript, python, ruby, clojure) doing the same for C# is somewhat a nightmare, particularly for non-type aware languages.

For example, Imagine you have an api like `void do(List<Foo> foos)`. In the erasure environment of the JVM that looks like `void do(List foos)`. From python it's pretty easy to call with a `foos = [Foo()]`. But not so much if your python implementation needs to figure out how and if it can coarse it's `List` type into a `List<Foo>` type.


I don’t think that’s the case. You can absolutely implement a type-erased language on top of the CLR. Your language will just have the same constraints of a type-erased language like Java.

Having reified generics in the CLR just lets you store more type information. There isn’t much of a trade off for CLR end-users.

Compare this to the constraints and workarounds that Kotlin and Scala have due to type-erasure on the JVM.


> Compare this to the constraints and workarounds that Kotlin and Scala have due to type-erasure on the JVM.

The creator of Scala disagrees: https://youtu.be/Xn_YpUtXWT4?t=850


Not necessarily. You can ignore the reified generic system in the CLR and monomorphize it in the CIL output for your language. Debugging for users is usually a nightmare though due to the monomorphization. The benefit of a type-erased runtime is the interop between the languages built on the runtime.

The monomorphization of CLR generics is what NativeAOT does, though it doesn't support some C# features.

TypeScript is essentially C#, but with type-erasure and lacking the low-level struct & pass-by-reference features.

I do think the C#/CLR struct implementation is better though.


You CAN do it, but it's much more difficult.

And as far as I'm aware, both kotlin and Scala don't really suffer due to type erasure.


I mean, the language is what it is. But, it definitely constrains the language developers. Especially when considering interop with other JVM languages.

That being said, it is easier to write a language on top of the JVM with good interop, since there are less ways to implement features. Essentially, your language has to interop with Java.

And it is harder to have good interop between CLR languages because there are more ways to implement features. Essentially, your language has to interop with C#.


IronPython did just fine with reified generics.


There's also https://github.com/ikvmnet/ikvm that converts Java bytecode to CIL; essentially Java on CLR.


Actually no it didn't, DLR was created exactly to support dynamic languages on the CLR.

Nowadays largely abandoned, and I think not everything survived the transition from .NET Framework into modern .NET.


The DLR is just a shared library. It never changed anything about the CLR under the hood nor did it change anything about how Generics worked in the CLR (being the most relevant part of the thread here). IronPython could instantiate Generics just fine, and did just fine working with them.

The DLR is not "abandoned" so much as "complete". Everything did survive the transition into modern .NET and IronPython 3.4.2 runs just fine on .NET 6+ [1]. (For those trapped on the ugly side of the Python 2/3 split, even IronPython 2.7.12 runs on .NET 6.) Most of the "magic" of the DLR is shared with C# Linq in interesting ways (the System.Linq.Expressions AST) and sort of "has to survive" if only to properly support IQueryable<T> (and wilder relatives like IQbservable<T>, the Q is not a typo) even if most people aren't actively using DLR languages today (nor that much usage of C#'s `dynamic` keyword).

IronPython is a community supported (truly open source) language so doesn't get as much attention now as it did in the brief "first party" support era, so some may call that "abandoned" but F# has always lived in that gray space where it is primarily "community supported" more than "officially supported" by a dedicated team at Microsoft.

[1] https://github.com/IronLanguages/ironpython3/releases/tag/v3...


The addition of dynamic, and JIT being aware of IDynamicMetaObjectProvider also mattered.

Even though most of the communication around dynamic was about COM support and Excel.


IDynamicMetaObjectProvider mostly just caches and returns System.Linq.Expressions for various asks, so again most of the JIT awareness is still just System.Linq.Expressions awareness.

The COM support is by implementing IDynamicMetaObjectProvider in a cross-language reusable base class.

The C# `dynamic` keyword adds some smarts about IDynamicMetaObjectProvider to the C# compiler, but those smarts were never seen to be needed as a low level tool in the CLR itself. And again, once the C# compiles the IDynamicMetaObjectProvider calls, those mostly just result in System.Linq.Expressions for the JIT compiler to optimize in the same way it optimizes other usages of Linq.


Yeah, that’s a different issue. Statically-typed languages, including type-erased languages, are fine on the CLR. Dynamically-typed languages are a different beast.

I suppose DLR would be comparable to GraalVM/Truffle.

The difficulty of implementing a dynamically-typed language directly on the CLR and JVM are about the same. Though, it would probably be more efficient on the CLR with access to lower level operations for memory management.

I think an interesting project would be to implement a CIL interpreter on GraalVM.


DLR inspired the addition of invokedynamic opcode on the JVM, which is actually the foundation for how lambdas get generated, many still think nested anonymous classes are used.

GraalVM already handles LLVM bitcode, much cooler than plain MSIL.

And here there is another example where Java ecosystem ends up being better.

MSR had a compiler framework similar to GraalVM, called Phoenix, it was going to replace VS, LLVM style, instead it died and what is left are a couple of research papers.


> many still think nested anonymous classes are used.

Anonymous classes are still used (sometimes). It simply depends on the circumstance of the lambda.

For example, this will result in a new anonymous class being generated.

    void foo(String s) {
      stream.filter(i->s.equals(i));
    }
The class gets generated to capture the `s` variable. Indy gets used in the `filter` method because the incoming lambda or method reference could be several types of method calls. For example, a constructor, an instance method, or a static method (I believe there are few more in the JVM bytecode).

What won't generate a new anonymous class is this sort of lambda

    stream.filter(i->"foo".equals(i));
That will generate a new method on the parent class which ultimately gets called. Since nothing is captured it can be directly called without a new instance being created.


I had another idea given Brian's talk on the matter.

Never bothered to actually look into the generated bytecodes.


Surprisingly hard to find good docs on this. [1]

I was wrong, it looks like the case I gave is one which doesn't result in a new anonymous class being generated. Instead the lambda metafactory gets involved to avoid that allocation.

I apparently didn't see what I thought I saw. I thought that I had seen new `lambda.$1` classes being created in call stacks when debugging. Maybe I did, but the lambda was serializable (we have that in some unfortunate places in our code base).

There are still cases where new classes get generated, but that's pretty much just for serialization.

[1] https://cr.openjdk.org/~briangoetz/lambda/lambda-translation...


I've been let down by structs in C# repeatedly. First of all, there are no constructor guarantees and you can never fully avoid them representing an illegal state. Which, wouldn't be so bad if there was some kind of post-construction validation, but this also isn't part of the language.

This is fine if you hand-roll all your code yourself, but I often use mapping libraries to lower the code footprint and the problems resulting from schema changes are subtle and fly under the radar. This is different from classes with hard construction guarantees, which Java would offer with their "integrity by default" mantra. Where you can opt out of integrity for performance benefits (which is also part of the design).

And Nullability in C# is an absolute nightmare. The type system has completely different rules for nullable types that generalize over classes and structs and there is no generic such as a "Nullable type".

It's just lots of minor annoyances that don't form a cohesive whole.


I hate to say it. But thats user error. The struct paradigm is different from classes. Structs are meant to be plain-old data types; simply a typed span of memory.

Structs are values, classes are entities with encapsulation.

The shape of the state would be structural. Whether or not the data in that shape is valid is behavioral.

Structs are useful when working with spans of memory.

Another example of a good usage of struct is Guid, which is 128 bits of data packed together.

The C# equivalent to Java ‘value class’ would be a class with a struct encapsulated for data. The data is flattened and allocated on the heap like Java. Similarly, escape analysis could stack allocate the class at runtime.


This is what makes the nuances of Valhalla's Value types so compelling. The idea are granular structs with "Integrity by default", where you can selectively give up constraints on class design to get performance characteristics.

Structs in most languages simply bunch a couple constraints together to get another set of performance benefits, but there's no law stating that they couldn't be singled out. In the design of Valhalla, it states that types can come in 4 buckets:

1: Fully identity classes (total control, mutable)

2: Value Based classes (no mutability, but full integrity and dense memory layout)

3: Implicitly constructed values (forced empty default constructor for swift bulk array initialization)

4: Tearable Values (No cross-field integrity during runtime for parallel access)

And I bet that for a vast majority of developers, #4 will come to a shocking surprise, thinking "values are threat safe" because they are told to use immutables.

This way of splitting up structs is the real interesting part of Valhalla, but this shitty AI-generated article buries everything interesting.


Imo #4 is why it’s not that useful. If the data is larger than an atomic read/write op the data isn’t flattened and it’s a regular object with value equality and immutability.

You have to opt into force flattening, and then it’s the same as a struct, except it’s still heap allocated without escape analysis. You still have to implement synchronization to prevent tearing.

Static code analysis can give you a warning for potential tearing of structs.

DotNext.Threading provides Atomic<T> to enable high-performance atomic operations on structs without heap allocation.

https://dotnet.github.io/dotNext/features/core/atomic.html

The design of value classes just seems counteractive to its purpose: memory management. If I want to manage contiguous blocks of memory, let me manage contiguous blocks of memory. If I want to allocate something on the stack, let me allocate something on the stack.

The paradigms of struct vs object are too different and they’re trying to combine them into one.


Thanks, I'm always happy to learn more about .net and the CLR.

Regarding #4, is this actually a done deal? I haven't dug into the JVM specifics, but I thought they would avoid allocating objects. And for now they just want to get the model right, while continue to optimize as time moves on. I think that's the right approach.

I actually see this way less critical because if you truly have performance-critical usage of structs, you know what you are doing. And if you know what you are doing, you will know about opting-in.

And for everything else? I think it's nice to have a range of benefits that come from having a value type without handing a gun to a monkey. Because the feature will be misused by people that don't know about tearing, thinking "value" is a free performance upgrade. And I do believe that it is the right mental model to reason about it.

I just don't see the huge issue. If the CLR has a way to provide atomic access to non-tearable structs, surely the JVM can too? We are talking CPU instructions here after all. Or am I missing something?


It is beyond me why I would get downvoted for legitimately pointing out shortcomings. I find it honestly frustrating how some people believe that “their language is best”. Until you point out real existing inconsistencies…


The gotcha is the potential boxing of structs onto the heap, but that can be avoided using `ref struct`s.

https://news.ycombinator.com/item?id=48599273


Why would you presume the parent is "just handwaving"? It's odd how people in the .NET community struggle to earnestly engage in conversation with Java folk. The reverse isn't true.


The article has a section about that.

For me, a struct in C/C# can be modified and is passed by copy while a value class can not be modified and is passed by value.

I do not think you can do stack allocation in Java.


Like @layer8 said, pass by copy and pass by value are the same.

C# copies C++ behavior where you can pass a struct by value or reference, and you can mark the parameter as readonly. C# also has in/out parameters. Essentially, you can program in C# exactly like you would in C++.

https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

The footgun with C# structs are that you can accidentally box them onto the heap. To avoid that you can define `ref struct`s that cannot be boxed. `ref struct`s follow the C# disposable pattern.

https://learn.microsoft.com/en-us/dotnet/csharp/programming-...

https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...


I don’t see a difference between pass by copy and pass by value.

The mutability difference is that part of a struct can be modified in place, which value classes can’t: the value of a complete value-class variable (or array slot) can only be modified (reassigned) as a whole. This is presumably because object references to value-class objects can be created, and those objects should be immutable so their identity doesn’t matter.


I think pass by copy is a consequence of being modifiable.

The other solution is to stack allocate and pass a pointer but as i said, unlike in C#, i do not think it's possible to do that in Java.

In Go, you can stack allocate but when you send a pointer (that escapes), the compiler will heap allocate the object.


My point is that pass by copy and pass by value do the same thing, they copy the value representation. In other words, pass by copy means exactly pass by value.


Actually, Java only has pass-by-value, even for reference types. (The same way as C does).

People really misuse/misunderstand this term: Java objects are passed by their pointers ("references") being copied.

The alternative is pass by reference, which is done by e.g. c++, rust, who actually have references (Java doesn't). A good litmus test is whether you can write a swap method that actually changes your local variables.


Rust is also “pass reference by value,” not pass by reference.


For me, the difference is that if methods are inlined, the compiler is still required to do a copy for structs but not for value classes.

I do not know how this is called.


I think that's mostly a semantic difference - Java avoided the problem of strange lifetimes, captures, tearing by fixing the semantics as immutable value objects, while C# has to deal with these issues.

But under the hood it can (and will) do a modification in place.


I made a comment on my understanding of the difference in implementation here: https://news.ycombinator.com/item?id=48606173

The ramifications for backwards compatibility is that the JVM won't have CLR features such as stackalloc (allocation of blocks of memory on stack), ref parameters (pass-by-reference of stack allocated value types), and all the other low-level/high-performance programming features available in the CLR.


> allocation of blocks of memory on stack)

Why couldn't it be done? Like sure, it won't happen in every case (e.g. too big value classes), but for a typical local variable of a value type, it's a trivial optimization to make it stack allocated. Even now it happens quite often (requires escape analysis), but the semantics change of value classes allows for it to be done "freely".


C# stackalloc returns a ‘ref struct’ which has certain restrictions and would be a Q-world type.

Java chose to go with an L-world implementation where everything is still a reference type on the heap, but memory management is more efficient via flattening. That’s why it’s a ‘value class’ and not a ‘struct’.

Primitive wrappers are scalarized into registers.

Stack allocated value types would have different semantics and is incompatible with the L-world implementation. Sure, they could implement it, but it would be the Q-world implementation that they decided to forego.

They can do automatic stack allocation for optimization via escape analysis as you mentioned. But, stackalloc is user allocated.

https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...


The C# equivalent to Java ‘value class’ would be a class with a struct encapsulated for data. The data is flattened and allocated on the heap like Java. Similarly, escape analysis could stack allocate the class at runtime, and they can be scalarized like C# structs.

Java ‘value class’ only flattens if the total size of the class data fits within an atomic read/write op. You can force it to flatten, but you may have tearing like C# struct.


Even closer would be a C# ‘readonly record struct’. Though, it would be allocated on the stack unless you box it.


Functionally they don't - java is just catching up with (by now) ancient practice.

The false dichotomy of

> A struct in C# has identity and mutation, so the semantics of copying on assignment or passing have to be precisely defined, which gives a heavier model for the programmer and less freedom for the runtime.

Doesn't really match with what they're describing. While yes, it will not have identity in a java class ref sense, it of course will still have identity in being a unique structure in memory at a certain address. This is just splitting hairs about Java nomenclature.


> it of course will still have identity in being a unique structure in memory

No, it will not. The design allows multiple objects to share one structure in memory across multiple records, or not have such a structure at all (see Scalarization in the article).


> The design allows multiple objects to share one structure in memory across multiple records, or not have such a structure at all

Yes. I fear you are missing the point.


I don't wanna badmouth Java people, but how they push the idea that this thing is some sort of genuine breakthrough that took multiple PhDs years of cutting-edge research to implement, when in fact they basically copied what .NET did from basically year 1, is not a good look.

Again, not trying to turn this into a .NET vs Java thing, I'd have been much happier if they reached some new and interesting conclusions.


> genuine breakthrough

Well, it is - because they had to make it with almost perfect backwards compatibility for one of the most popular languages with trillions of lines of code produced over decades.

Sure, adding it to a new language is not hard. Adding it to Java which has primitives, generics and boxing, finding ways that seamlessly cover the differences between objects and primitives, while trying to plan for the future is hard.

As a general note, if you come to the conclusion that one of the best designer teams on Earth "basically copied what .NET did from year 1 is not a good look", then maybe your mental model needs adjusting on how these stuff works? Java has a public mailing list, you can browse through the related discussions. Implementation is the least of these things. But I can assure you they most definitely know what they are doing.


> because they had to make it with almost perfect backwards compatibility for one of the most popular languages with trillions of lines of code produced over decades.

At what cost? A key benefit of value types is improved performance but AFAIK Valhalla doesn't even let you pass them by reference. Efficiently passing them through registers is great but won't help you out with larger value types.


idk maybe java should adopt something similar to rust's "edition"?


Correct me if I'm wrong, but Rust editions are a source code-level feature. So given you have the source code of newer and older rust code, you can compile them together.

That's materially distinct from Java's model of basically dynamic loading already compiled class files. Though class files do have "editions", and there are extra code to deal with different versions. But still, it should be possible to e.g. send a new value class to an old library's class that has never heard of them, and that should just work.


The important thing is that Rust editions affect semantics and name resolution. In such an analogy, JVM bytecode is the equivalent of Rust code - various semantics are baked in, but stuff like name resolution isn't (at least not completely).


Except of course they are breaking backwards compatibility, in relatively subtle ways, for anything that uses the standard library wrapper types. So every use of Integer, Boolean , etc is an opportunity for either a compilation error or a runtime bug.


Honestly, if you're relying on `new Integer(999) == new Integer(999)` to be false, you've earned your bug.


>I don't wanna badmouth Java people, but how they push the idea that this thing is some sort of genuine breakthrough that took multiple PhDs years of cutting-edge research to implement, when in fact they basically copied what .NET did from basically year 1, is not a good look.

Oversimplifying a big semantic and backend change to a huge codebase on which some of the most crucial customer and government and business systems depend on, and which has to be made as seamless, correct, and performant as possible, to "they just copied .NET", just because .NET has the same functionality, is an even worse look.

It's a "HN "Dropbox is just rsync + some scripts"-style bad look.


This is exactly what made it so difficult. It is much easier to have a feature like this from year 1 than to add it to a language that has grown and evolved for 18 years already.


I agree with this sentiment. The work they put in deserves a lot of respect, and took a lot of effort, no doubt. It's just the framing they push to the public that could use some work.


The article is shit, the actual concept and roadmap goes well beyond the capabilities of C# or the CLR.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: