Creating a pointer type in a managed language like C# may sound like heresy, but C# actually has most of the features necessary to create a pointer type.
You may be thinking, "how are you going to create a pointer if you can’t get the address of a managed object?" But that’s totally possible! There is an undocumented keyword, __makeref, that can help us do this.
__makeref returns a structure, TypedReference. A TypedReference acts very much like a pointer itself. It’s commonly used in Reflection, notably when (directly) retrieving the value of a field. A TypedReference contains two IntPtr fields: "Value", and "Type". We can make an educated guess that "Value" is the actual address of the object. Therefore, with the power of casting, we can use a TypedReference to get the address of a managed object:
Now that we can get the address of an object, we can create a pointer type. First, we’ll add one field containing the address we’re pointing to:
To make our pointer as authentic™ as possible, we’ll make it a struct so it behaves like a C/C++ pointer and like a reference type in C#.
We’ll make m_value our only field so it is represented just like a pointer in memory. It’ll also keep the size of the type the same size as a native pointer, 4 or 8 bytes.
If you’re at all familiar with C/C++ pointers, you know you can index them just like arrays. All that’s up to us is doing the arithmetic behind the scenes. So we’ll make a method to calculate the correct address to read from. The math is very simple, just multiply the index by the size of the type:
But you may be thinking, "sizeof doesn’t work with managed types!" But thankfully, CompilerServices.Unsafe provides us with a SizeOf<T> method. Even then, the size of a managed type is just the size of a pointer, 4 (32-bit) or 8 (64-bit) bytes, so you don’t actually have to use SizeOf<T> if you don’t want to.
CompilerServices.Unsafe also supplies us with a method for reading/writing to addresses, so we will use that to implement the getter/setter for our indexer:
Now we have an indexer, but we’re not done yet. In C/C++ you can also postfix increment and decrement pointers just like integers. To do this, we’ll also use the Offset method we wrote earlier to create an Increment and Decrement method, and overload the postfix operators.
At this point we have most of the traits that make up a pointer. All that’s left is adding a few things to polish things up. We’ll create an implicit operator for synergy with our AddressOf method:
Now we’ve written pretty much everything that defines a pointer. You can add a few more features yourself, such as an Address property or overloading the add and subtract operators.
Of course, pointers aren’t just used for pointing to other objects, but for dynamic memory allocation, handles, and arrays. (I’ll write an article on all those some other time.) But for now we’ll stick to pointing to other objects.
We’ll make a string and tell our pointer to point to str‘s address. We can read and write to both objects no problem.
There’s an important thing to note here if you aren’t super familiar with reference types and pointers. What we’re really doing here is pointing to a pointer. We aren’t pointing to str itself in heap memory. Because a string is a reference type, it’s actually a pointer internally. The runtime does the dereferencing and pointer mechanics behind the scenes for you.
If you know a thing or two about the garbage collector, you know the garbage collector compacts the heap, which changes the address reference types point to. However, the address of the reference type’s pointer doesn’t change, only the address of what the reference type points to.
Here, we create a ton of objects to make the GC run and compact the heap. We make sure our pointer points to the correct object and has the correct value.
As you can see, str pointed to a different address in the heap after the GC ran. This test would fail if our pointer pointed to the heap memory of str, but it didn’t. For an explanation, continue reading.
Like mentioned earlier, you’ll run into problems if you point to the heap memory of reference types because the GC compacts the heap. This actually can be avoided if you pin an object, but we won’t go into detail about that.
To get to the heap memory of arr, we need to read arr‘s heap pointer. Then we need to offset that pointer by IntPtr.Size * 2 (skip over the MethodTable*, padding Int32, and length Int32.) (I explained this in my first article.)