Skip to content

Instantly share code, notes, and snippets.

@atcarter714
Last active September 7, 2023 22:01
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save atcarter714/16940787825a56f82fac22aa967e4a75 to your computer and use it in GitHub Desktop.
Save atcarter714/16940787825a56f82fac22aa967e4a75 to your computer and use it in GitHub Desktop.
Clearing up the confusion about how null-ness of Unity Objects works with a very simple simulation of it!
#include <stdlib.h>
#define DllExport __declspec( dllexport )
// Some simple native C code to simulate how Unity does its magic in native code:
DllExport void* __cdecl allocateMem( size_t sizeInBytes ) {
return malloc( sizeInBytes );
}
DllExport void* __cdecl allocateMemFor( size_t count, size_t size ) {
return calloc( count, size );
}
DllExport void* __cdecl reallocateMem( void* ptr, size_t newSize ) {
return realloc( ptr, newSize );
}
DllExport void __cdecl deleteMem( void* pMem ) {
free( pMem );
}
DllExport size_t __cdecl getAllocSize( void* pAllocated ) {
return _msize( pAllocated );
}
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// Here we simulate a managed object using native memory resources which has the same
// behavior as a Unity Object with "null-ness" and checks. It demonstrates why if(obj)
// is usually how you should be doing your checks ...
internal class Program
{
private static void Main( string[] args )
{
// Create simulated Unity object:
UnityObjectWay? obj = new( "Hello, World, from the unmanaged heap!" );
WriteInColor( "Created object, now calling obj.Print() ...\n", ConsoleColor.Cyan );
// Call its public method and then evaluate it:
obj.Print();
Evaluate( obj );
// Now call Destroy on it and evaluate it again:
WriteInColor( "\nNow calling Destroy!", ConsoleColor.Red );
UnityObjectWay.Destroy( obj );
Evaluate( obj );
// Try it by assigning null directly:
WriteInColor("\nNow assigning null (literal) to obj:", ConsoleColor.Magenta );
obj = null;
Evaluate( obj );
}
public static void Evaluate( UnityObjectWay? obj )
{
var lineColor = ConsoleColor.Gray;
WriteInColor("\n----------------------------------", lineColor );
WriteInColor( "Trying if( obj ) check:" );
WriteInColor("----------------------------------", lineColor );
if ( obj )
WriteInColor( "\tobj evaluated as non-null!\n", ConsoleColor.DarkGreen );
else WriteInColor( "\tobj evaluated as null!\n", ConsoleColor.DarkRed );
WriteInColor("----------------------------------", lineColor);
WriteInColor("Trying if( obj != null ) check:");
WriteInColor("----------------------------------", lineColor);
if ( obj != null )
WriteInColor("\tobj evaluated as non-null!\n", ConsoleColor.DarkGreen );
else WriteInColor("\tobj evaluated as null!\n", ConsoleColor.DarkRed );
WriteInColor("----------------------------------", lineColor );
WriteInColor( "Cast to real C# object and check:" );
WriteInColor( "----------------------------------", lineColor );
var sysObj = (object?)obj;
if ( sysObj is not null )
WriteInColor( "\tIt's not null in C# terms ...\n", ConsoleColor.DarkGreen );
else WriteInColor( "\tIt's null for real!\n", ConsoleColor.DarkRed );
WriteInColor( "____________________________________________________________________________", ConsoleColor.DarkGray ); ;
}
public static void WriteInColor( string text, ConsoleColor color = ConsoleColor.White ) {
var originalColor = Console.ForegroundColor;
Console.ForegroundColor = color;
Console.WriteLine(text);
Console.ForegroundColor = originalColor;
}
}
public static unsafe class PInvoke
{
const string mylibName = "mylib.dll";
// --- Native DLL Functions:: --------------------- //
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern IntPtr allocateMem( ulong size );
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern IntPtr allocateMemFor( ulong count, ulong size );
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern IntPtr reallocateMem(IntPtr ptr, ulong newSize);
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern ulong getAllocSize(IntPtr pAllocated);
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern void deleteMem( IntPtr pMem );
// ----------------------------------------------- //
};
public unsafe class UnityObjectWay
{
const short INLINE = 0x100;
static uint id_counter = 0;
public UnityObjectWay( string str )
{
if( string.IsNullOrEmpty(str) )
throw str is null ?
new ArgumentNullException( nameof( str ) ) :
new ArgumentException( "Cannot allocate empty string!", nameof( str ) );
var pHeap = PInvoke.allocateMemFor( (ulong)str.Length, sizeof(char) );
if( pHeap == nint.Zero ) throw new InsufficientMemoryException("Allocation failed!");
this.ptr = (char *)pHeap;
this.count = str.Length;
this.sizeInBytes = charSize * count;
this.instanceID = id_counter++;
fixed ( char* pStr = str ) {
_writeToHeap( pStr, str.Length );
}
}
char* ptr;
int count;
readonly int sizeInBytes;
readonly int charSize = sizeof(char);
readonly uint instanceID = 0x00000000u;
// Helper methods for using native memory:
[MethodImpl(INLINE)] bool _writeToHeap( char[] chars ) => _writeToHeap( chars.AsSpan() );
[MethodImpl(INLINE)] bool _writeToHeap( char* pStr, int len ) => _writeToHeap( new Span<char>( pStr, len ) );
[MethodImpl(INLINE)] bool _writeToHeap( Span<char> pStr ) => pStr.TryCopyTo( new( ptr, count ) );
[MethodImpl(INLINE)] void _deleteBlock() => PInvoke.deleteMem( (IntPtr)ptr );
public void Print( ConsoleColor color = ConsoleColor.DarkYellow ) {
if ( ptr is null )
throw new NullReferenceException( "Object is null!" );
var originalColor = Console.ForegroundColor;
Console.ForegroundColor = color;
for( int i = 0; i < count; ++i )
Console.Write( ptr[ i ] );
Console.Write('\n');
Console.ForegroundColor = originalColor;
}
// --- Simulates how Unity Object nullability works: ------------- //
public static void Destroy( UnityObjectWay obj ) {
if ( !IsNativeObjectAlive(obj) ) return;
obj._deleteBlock();
obj.ptr = null;
}
static bool IsNativeObjectAlive( UnityObjectWay? obj ) {
if ( obj is null ) return false;
if ( obj.ptr != null ) return true;
return false;
}
static bool CompareBaseObjects( UnityObjectWay? a, UnityObjectWay? b ) {
bool aNull = a is null, bNull = b is null;
if ( bNull & aNull ) return true;
if ( bNull ) return !IsNativeObjectAlive(a);
return aNull ? !IsNativeObjectAlive(b) : a?.instanceID == b?.instanceID;
}
public static implicit operator bool( UnityObjectWay obj ) =>
!CompareBaseObjects( obj, (UnityObjectWay?)null );
public static bool operator ==( UnityObjectWay? a, UnityObjectWay? b ) =>
CompareBaseObjects( a, b );
public static bool operator !=( UnityObjectWay? a, UnityObjectWay? b ) =>
!CompareBaseObjects( a, b );
};
public static extern ulong getAllocSize(IntPtr pAllocated);
[DllImport(mylibName, CharSet = CharSet.Unicode)]
public static extern void deleteMem( IntPtr pMem );
// ----------------------------------------------- //
};
@atcarter714
Copy link
Author

unityNullnessSimulation

Uploaded attachment of the program output. This shows why you have to be careful and understand the nuances of null-ness of Unity Objects, by understanding that the C# class is not the same as the native engine object, and why most of the time the short and sweet if( obj ) check is what you're actually after! Using if( obj != null ) or if( obj is not null ) only checks if the C# object is null or not, and doesn't tell you if the engine actually has a valid native object to complete requests and do things with ...

@atcarter714
Copy link
Author

atcarter714 commented Nov 27, 2022

Important Note:

Oh no, this totally slipped my mind earlier but I didn't think about an overloaded == and != for making it consistent ... what reminded me was I heard an old Unite Austin 2017 video playing on my TV in the background and they were mentioning that they made the operator consistent with the implicit bool conversion op, so I updated this to make it the way the things work now by adding:

    public static bool operator ==( UnityObjectWay? a, UnityObjectWay? b ) =>  CompareBaseObjects( a, b );

    public static bool operator !=( UnityObjectWay? a, UnityObjectWay? b ) =>  !CompareBaseObjects( a, b );

That should correct things and make it behave like the real thing now ... what happens is that we will get the sort of "null-ness" behavior we want and expect in game programming whenever we do any check that goes through either the implicit bool conversion operator or the manually-defined == and != operators. Anything that bypasses the custom operators defined will have results that might baffle and totally confuse people.

However, Unity's "Object" class not only should have had a different name (e.g., like "DisposableObject") but it seriously should have implemented the IDisposable interface pattern, which is the "best practice" and standard for situations like these where managed objects have internal access to or control of native resources/memory they're responsible for freeing when they are no longer needed ... the IDisposable pattern is literally for this sort of thing specifically and has this sense of duality built right into it: and it makes it clear that the managed class being non-null doesn't mean the unmanaged resources/memory are not disposed/destroyed! Why they didn't implement IDisposable I truly do not know ... it's a standard sort of thing any .NET engineer is familiar with since way, way back, and also something you'd remember from XNA (or even MDX) if you've been doing C# game dev for a long time like I have. I can't think of any reasons IDisposable would have caused any issues other than purely stylistic concerns, as if someone was worried about making it "easy mode" and thought that IDisposable concepts would make it "too hard" ... in reality, I think not using the IDisposable pattern just created a whole lot of long-running confusion and issues for people. It's probably possible to change it, still, without ill-effects ... but they would probably be very reluctant to even consider it ...

@atcarter714
Copy link
Author

atcarter714 commented Dec 4, 2022

A further caveat I didn't even think of until now, as I became aware of it, is that Roslyn uses a "proper" null comparison when she encounters the (obj is null) style expression. It boils down to the simple ldarg, ldnull, ceq instructions: load the arg, load "null" and then do a conditional push of 0x1 (true) or 0x0 (false) ... that's the way the gods intended an evaluation of null-ness to work, haha! But there's a little nuance to be aware of with == and != because you can overload those on a user-defined type (as shown in the example) and override the default/built-in behavior. If a user-defined overload for == and != exists, then (obj == null) will actually be calling that instead of doing the "true null-ness" evaluation! And the result is gonna be whatever that user-defined operator wants it to be ...

This actually compiles to different IL code, which will look like this:

IL code for (a == null) evaluation:

IL_0010:  ldnull
IL_0011:  call bool A::op_Equality(class A, class A)
IL_0016:  stloc.0

IL code for (a is null) evalutation:

IL_0017: ldnull
IL_0018: ceq
IL_001a: stloc.1

So bear this little caveat/nuance in mind when checking the "null-ness" of something like "Object" types in Unity, or anything which has its own special overloads for == and != operators!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment