Skip to content

C# and .NET docs supplement

James Groom edited this page Nov 13, 2024 · 44 revisions

To save us repeating our complaints about the lack of proper documentation under each section, let's agree to gather all the frustration here:

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Contribute to the official docs if possible.

Allocation limits

Under .NET 8 (all of this is untested, just based on the docs and .NET issue tracker):

  • The largest 1D byte array is new byte[Array.MaxLength], Array.MaxLength being hardcoded to 0x7FFF_FFC7 or just under 2 GiB. (Would probably want to alloc in 1 GiB chunks to have nice code, maybe even smaller to keep the GC happy.)
  • The largest 1D struct array is new T[Array.MaxLength]. So its size is... unbounded since structs don't have a size limit apart from the stack size (and [InlineArray]'s poorly-documented 1 MiB cap). (TODO check there isn't an undocumented cap on struct size)
  • The largest n-D byte array is UNK() (LongLength is 0xUNK or UNK GiB).
  • If a call would allocate beyond those limits (or the process' or machine's limits), an OutOfMemoryException will be thrown, which can be caught and handled but definitely shouldn't be.
  • All of that is on the managed heap. The default/global stack is OS-specific and cannot be changed: On Windows, it's 1.5 MiB due to an oversight, and on Linux, it's inherited from the OS, typically 8 MiB.
    • The stack size for managed threads can be specified on init, though as with all of this you may have an XY problem and should reconsider.
    • If a call would allocate beyond the stack size (or a call chain grows out of control), a StackOverflowException will be thrown, which cannot be caught.

Under Mono (x64 unless specified and without gcAllowVeryLargeObjects, TODO see if Mono respects that):

  • The largest 1D byte array is new byte[int.MaxValue] (just under 2 GiB), as there is no way to instantiate an array with a larger size (Array.CreateInstance(typeof(T), 0x8000_0000L) will throw ArgumentOutOfRangeException: Arrays larger than 2GB are not supported., which I suppose is technically incorrect).
  • Similarly, the largest 1D struct array is new T[int.MaxValue]. So its size is... unbounded since structs on the heap don't have a size limit.
  • The largest n-D byte array is new byte[2, 0x7FFF_FFE6] (LongLength is 0xFFFF_FFCC or just under 4 GiB). Allocating a single byte more gives an OoME. Multidimensional arrays of other structs appear to also be limited to 0xFFFF_FFCC octets.
  • If a call would allocate beyond those limits (or the process' or machine's limits), the relevant builtin method will throw an OutOfMemoryException, which can be caught and handled but definitely shouldn't be.
  • All of that is on the managed heap. The default/global stack is UNK MiB large and can be changed externally with ulimit (a builtin in most POSIX shells, including BASH), but not with Roslyn.

Under .NET Framework on Windows (x64 unless specified):

  • Untested, but I'm guessing it's probably the same as Mono so long as the gcAllowVeryLargeObjects is enabled in the runtime config, going by those docs and this blog post.
  • The default/global stack is 1 MiB, specified in a PE header. This can be changed externally with EDITBIN, but not with Roslyn nor at runtime in any way.
  • It should be no surprise that all these limits were inherited from native Win32. Thankfully modern .NET is deviating from that where necessary.

BCL source

The API reference on Microsoft Learn (formerly MSDN) now links to the source in most places, but of course that's for modern .NET.

Const (byte/primitive) arrays

Not allowed as either array nor Span, despite string literals now effectively having the type const ReadOnlySpan<char>, and despite arrays of primitive types being allowed for attribute parameters since forever. Use static readonly and weep.

Const structs

Not allowed, even if they meet the criteria for unmanaged types and are littered with explicit layout attributes. First-class'd structs are no different, so fields of type ValueTuple, Range, and as noted above, Span cannot be const. Also ref structs, which makes slightly more sense. That's probably only because you'd be able to get a reference to a ref struct on the heap, something which shouldn't exist, by using reflection.

Deceptive collection type names

IReadOnly{Collection,Dictionary,List,Set} are for getting read-only views of the collections that implement them. They do not mean the collection is immutable (there are separate classes for that). The same goes for ReadOnlySpan.

Kotlin got this right by calling its interfaces e.g. List/MutableList instead of IReadOnlyList/IList. (And it also fixed the inheritance hierarchy.)

Featureset is determined by language level AND target

see feature matrix page

Guarded default in switch statements

You can only have 1 default: branch (not to be confused with case default:), but you can't simply add a guard clause: case _ when ...: doesn't work. However, case var _ when ...: does.

MemoryMarshal.Cast can't be used with reference types

See this SO answer. tl;dr: It's possible in .NET Core only.

MSBuild Condition placement

On (older versions of?) VS, Condition is ignored if placed on a property/item. Create a new <PropertyGroup/>/<ItemGroup/>.

MSBuild project evaluation (without build)

dotnet publish --getProperty

Example:

$> dotnet publish --getProperty:IsTargetingNetFramework src/BizHawk.Emulation.Common/BizHawk.Emulation.Common.csproj 
False
$> dotnet publish --getProperty:IsTargetingNetFramework src/BizHawk.WinForms.Controls/BizHawk.WinForms.Controls.csproj 
True

MSBuild path properties

Always use $(MSBuildProjectDirectory) rather than $(ProjectDir) (note that the former doesn't include a trailing slash), because when <Import/>ing a .props file, $(ProjectDir) is unset. ($(SolutionDir) is set, but that should be avoided even in the main solution.)

Use $(TargetPath) rather than reconstructing e.g. $(OutputPath)$(MSBuildProjectName).dll.

Newtonsoft.Json footguns

byte[] is intentionally hardcoded to serialise to a base64 string (as opposed to a list, like short[], int[], etc. are). The only workaround is to implement JsonConverter (already done), then either mark the field/prop, or pass this in the serialiser settings. (This behaviour also made it into System.Text.Json.)

If a string literal contains a date, even if it's being deserialised to a string, it will first be deserialised to a date, timezone-corrected, and re-serialised.

NuGet resources are all for old CLI

up-to-date docs on MSDN

dotnet list $PWD/BizHawk.sln package --outdated will list outdated <PackageReference/>s (betas are ignored without --include-prerelease—if there are only betas published, it sees no releases and prints "Not found"). There is no built-in command for updating them automatically.

NUL-terminated strings

.NET will happily include NUL ((char) 0) in a string if you use String..ctor(char[]). WinForms' Label.Text stops reading at the first NUL for measurement/rendering, at least under Mono.

[Obsolete] only works on base declarations

See dotnet/csharplang#2652.

Out-of-range Span slices throw ArgumentOutOfRangeException with wrong param name

See dotnet/csharplang#53622/dotnet/csharplang#90939.

Preprocessor TFM constants and .NET Standard

The table here is good for reference, but mind the note hidden at the bottom:

The NETSTANDARD<x>_<y>_OR_GREATER symbols are only defined for .NET Standard targets, and not for targets that implement .NET Standard [...]

That is, you must use #if !(NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER) and not just #if !NETSTANDARD2_1_OR_GREATER.

Signed/Unsigned byte array casting

As Jon Skeet himself explains here, "Even though in C# you can't cast a byte[] to an sbyte[] directly, the CLR allows it". This leads to some weird behaviour, since the compiler is hardcoded to replace only some type checks with consts.

String.GetHashCode stability

The GetHashCode implementation for strings does not reflect the string's contents, and as such, the hash not stable between program instances.

It seems that Guid's implementation is stable across instances, and even across Mono and .NET 6+ implementations. It also gives 0 for Guid.Empty which is nice.

System.Drawing.Color.* rendered

Docs for Color don't include any pictures, so here's a nice chart.

System.Drawing.SystemIcons rendered

Docs for SystemIcons don't include any pictures, so here they are (Win10, Mono 6.12.x):

SystemIcons_Win10 SystemIcons_Mono

Notice also the default window icon (Form.Icon): on Windows, it's a distinct icon; on Mono (not shown in the screenshot), it resembles SystemIcon.Application. From 2.9, EmuHawk overrides the default to the logo.

[ThreadStatic] field initialisation

Per docs (simpler), static fields initialisation is moved to the static constructor in IL, which runs on at most 1 thread, so a [ThreadStatic] field will be default on all other threads if initialised in the usual way.

Incorrect usage in BizHawk should be flagged with CA2019, but apparently it's not working in CI.

Type casting

There are two types of casts in C#: the C-style (T) o throws if the object is not of the desired type, whereas o as T evaluates to null if it's not of the desired type. There's no '?' in this null-producing operator (this is probably only confusing if you use Kotlin).

If an object being the wrong type is exceptional—the method can't handle it gracefully—then throw a type cast exception straight away. Having it reported as an NRE when there's no null in sight just frustrates debugging efforts.

Type constraints (where clauses)

class in where clauses does not mean "not abstract", it means "reference type". Similarly, struct means "value type". There's a lot of complexity re: nullability, so check the docs if you're writing a generic method.

TODO euler diagram

WinForms Control.ResumeLayout footgun

// works
groupBox.ResumeLayout(performLayout: false);
groupBox.PerformLayout();
// breaks subtly
groupBox.ResumeLayout(performLayout: true);