Search code examples
c#types

Are there any Int24 implementations in C#?


I have a project that I am working on where I require a data type that doesn't exist in the .NET BCL - an Unsigned Int24. For calculations that I am doing, the 4th byte in an int32, even when set to all zeroes, screws up my results.

EDIT: I'm doing bitwise circular shifts on a 24bit integer space that is limited to only 24bits. If the rotation is performed at 24bit level on a 32bit number the result is wildly incorrect.

Does anyone know of any third party implementations of this data type that are available?

Thanks!


Solution

  • Implementing Int24 isn't hard (honest!). But we need to know more about why you need to implement it. @nneonneo wonders if you're trying to interface with a native library that uses 24-bit integers. If that's the case then you can be done by doing something like this:

    [StructLayout(LayoutKind.Sequential)]
    public readonly unsafe struct UInt24
    {
        private readonly Byte b0;
        private readonly Byte b1;
        private readonly Byte b2;
            
        public UInt24(UInt32 value)
        {
            this.b0 = (byte)( (value      ) & 0xFF );
            this.b1 = (byte)( (value >>  8) & 0xFF ); 
            this.b2 = (byte)( (value >> 16) & 0xFF );
        }
            
        public unsafe Byte* Byte0 { get { return &_b0; } }
    
        public UInt32 Value { get { return _b0 | ( _b1 << 8 ) | ( _b2 << 16 ); } }
    }
    

    This type really does occupy only 3 bytes, at least as far as .NET's concerned:

    UInt24 u24 = new UInt24( 16_777_215 );
    
    Console.WriteLine( "Marshal.SizeOf<UInt24>(u24) == {0:N0}", Marshal.SizeOf<UInt24>(u24) );
    Console.WriteLine( "sizeof(UInt24)              == {0:N0}", sizeof(UInt24)              );
    
    // Prints:
    // 
    // Marshal.SizeOf<UInt24>(u24) == 3
    // sizeof(UInt24)              == 3
    

    Anyway, you'd be using it like so:

    [DllImport("foo.dll")]
    private static unsafe void SomeImportedFunction(byte* uint24Value);
    
    public static unsafe void Foo()
    {
        UInt24 uint24 = new UInt24( 123 );
    
        SomeImportedFunction( uint24.Byte0 );
    }
        
    

    Modifying the class for big-endian, signed Int24, or the bitshifting operators that the OP is asking for, is an exercise left up to the reader.


    UPDATE: A fleshed-out UInt24

    I recently found myself needing to use a UInt24, so I took my existing impl and fleshed it out by adding (only safe) implicit conversions (to wider types, from narrower types), IEquatable<UInt24>, IComparable<UInt24>, etc). My projects are (still) targeting only .NET Framework 4.8 and .NET 6, so I can't use INumber<T> for now, but here:

    [CLSCompliant( isCompliant: false )] // <-- Because UInt32 is not CLS-compliant, *grumble*.
    [StructLayout( LayoutKind.Sequential )]
    public readonly struct UInt24 : IEquatable<UInt24>, IComparable<UInt24>
        // Implementing .NET 7's new `INumber<UInt24>` is an exercise for the reader.
    {
        public static readonly UInt24 MaxValue = new UInt24( 0xFF, 0xFF, 0xFF );
        public static readonly UInt24 MinValue = new UInt24( 0x00, 0x00, 0x00 );
    
        private const UInt32 _maxValue = 0x00FFFFFF; // 16,777,215 // This is the *inclusive* upper-bound (i.e. max-value).
    
        //
        
        public static implicit operator UInt32( UInt24 self ) => self.Value;
        public static implicit operator UInt24( UInt16 u16  ) => new UInt24( value: u16 );
    
        //
        
        private readonly Byte b0;
        private readonly Byte b1;
        private readonly Byte b2;
        
        public UInt24( Byte b0, Byte b1, Byte b2 )
        {
            this.b0 = b0;
            this.b1 = b1;
            this.b2 = b2;
        }
        
        public UInt24( UInt32 value )
        {
            if( value > _maxValue ) throw new ArgumentOutOfRangeException( paramName: nameof(value), actualValue: value, message: $"Value {value:N0} must be between 0 and {_maxValue:N0} (inclusive)." );
            
            //
            
            this.b0 = (Byte)( ( value       ) & 0xFF );
            this.b1 = (Byte)( ( value >>  8 ) & 0xFF ); 
            this.b2 = (Byte)( ( value >> 16 ) & 0xFF );
        }
    
    #if UNSAFE
        public unsafe Byte* Byte0 => &_b0;
    #endif
    
        private Int32  SignedValue => ( this.b0 | ( this.b1 << 8 ) | ( this.b2 << 16 ) );
        public  UInt32 Value       => (UInt32)this.SignedValue;
    
        //
        
    #region Struct Tedium + IEquatable<UInt24> + IComparable<UInt24>
    
        public override String  ToString   ()                                  => this.Value.ToString();
        public override Int32   GetHashCode()                                  => this.SignedValue;
        public override Boolean Equals     ( [NotNullWhen(true)] Object? obj ) => obj is UInt24 other && this.Equals( other: other );
        
        public          Boolean Equals     ( UInt24 other )                    => this.Value.Equals   ( other.Value );
        public          Int32   CompareTo  ( UInt24 other )                    => this.Value.CompareTo( other.Value );
        
        public static   Int32   Compare    ( UInt24 left, UInt24 right )       => left.Value.CompareTo( right.Value );
        
        //
        
        public static Boolean operator==( UInt24 left, UInt24 right ) =>  left.Equals( right );
        public static Boolean operator!=( UInt24 left, UInt24 right ) => !left.Equals( right );
        
        public static Boolean operator> ( UInt24 left, UInt24 right ) => Compare( left, right ) >  0;
        public static Boolean operator>=( UInt24 left, UInt24 right ) => Compare( left, right ) >= 0;
        
        public static Boolean operator< ( UInt24 left, UInt24 right ) => Compare( left, right ) <  0;
        public static Boolean operator<=( UInt24 left, UInt24 right ) => Compare( left, right ) <= 0;
        
    #endregion
    }
    

    Because it supports implicit conversions from UInt16 values - and implicit conversions to UInt32 values - it means you don't need to use ctor calls, observe:

    UInt24 u24_1 = new UInt24( 16_777_215 ); // <-- This uses the UInt32 ctor overload, which is OK here because 16,777,215 is <= UInt24.MaxValue.
    
    UInt24 u24_2 = 2_000_000_000; // <-- The compiler flags this with CS0266 because an integer literal value of 2 billion has a type of `Int32`, but `UInt24` does not define implicit conversion from `Int32` - only `UInt16`.
    
    UInt24 u24_3 = 65535; // <-- This is fine, because a literal of 65535 is typed as UInt16 by the compiler, which is a defined implicit conversion.
    
    UInt32 asUnsigned32BitInteger = u24_1; // <-- This is also OK, because an implicit conversion to UInt32 is defined on UInt24.
    
    • Implicit conversion from Int16 to UInt24 is not defined because Int16 represents -32768 to 32767, whereas UInt24 represents 0 to 16777215 - so some Int16 values cannot be meaningfully represented by UInt24 hence why it's disallowed.

    • Implicit conversion from UInt24 to Int32 can be defined, because every UInt24 value (0 to 16.7m) can also be represented by Int32 (-2bn to +2bn), however it made more sense to me to define UInt24 to UInt32 to retain the unsigned theme going on here.


    C# 12 + .NET 8 approach: Inline Arrays

    C# 12 allows us to slap \[InlineArray(Int32)\] onto a single-field struct and the compiler/runtime will treat it as an array of that struct...

    ...which got me thinking that an interopable UInt24 type could then be defined as an inline array of 3 bytes, thus allowing a struct to be type-punnable as Byte[3] or Span<Byte>(3) - just like the OP's question.

    (I should note that one of C#/.NET's leads, David Fowler, urges only doing this if you have a "very advanced scenario" such as demoing new language features on X/Twitter, I guess 😺)

    Like so:

    void Main()
    {
        UInt24 secretlyImAnArray = default;
        
        for( Int32 i = 0; i < UInt24.Length; i++ )
        {
            secretlyImAnArray[i] = (Byte)i;
        }
        
        Console.WriteLine( "UInt24.ToString(): {0}", secretlyImAnArray ); // Prints only "0x00"
        
        foreach( var b in secretlyImAnArray ) // `var b` is correctly inferred to `Byte b` by Roslyn.
        {
            Console.WriteLine( b ); // Prints "0", "1", "2"
        }
        
        //
        
        Span<Byte> asSpan = secretlyImAnArray;
        
        Console.WriteLine( "asSpan.ToString(): {0}", asSpan.ToString() ); // Prints "asSpan.ToString(): System.Span<Byte>[3]"
    }
    
    [InlineArray( length: UInt24.Length )]
    public struct UInt24
    {
        public const Int32 Length = 3;
    
        public Byte b;
    
        //
    
        public override String ToString() => "0x" + this.b.ToString( "X2", CultureInfo.CurrentCulture );
    }