SomeRanDev / reflaxe.CSharp

A remake of the Haxe/C# target written entirely within Haxe using Reflaxe.
MIT License
34 stars 2 forks source link

Enums #2

Open SomeRanDev opened 1 year ago

SomeRanDev commented 1 year ago

How to make enums? Is there a variant type for C#? Would it be appropriate to just store arbitrary data as object, then cast upon request if the index matches up? Would lose support for struct types tho.

Simn commented 1 year ago

See https://github.com/HaxeFoundation/haxe/pull/6119

We also spent quite a bit of time designing this for the JVM target to make it efficient. Here's the decompiled code for the base class haxe.jvm.Enum:

package haxe.jvm;

public class Enum<T extends java.lang.Enum<Object>> extends java.lang.Enum<java.lang.Enum<Object>> {
    public <T> boolean equals(Enum<java.lang.Enum<Object>> other) {
        return super.equals(other);
    }

    public String toString() {
        String baseName = (String)Type.getEnumConstructs(Type.getEnum((java.lang.Enum)this)).__get(this.ordinal());
        Array parameters = Type.enumParameters((java.lang.Enum)this);
        return parameters.length == 0 ? baseName : "" + baseName + "(" + parameters.join(",") + ")";
    }

    public Enum(int index, String name) {
        super(name, index);
    }
}

And then each actual enum extends that:

package haxe.ds;

import haxe.jvm.Enum;
import haxe.jvm.Jvm;
import haxe.jvm.annotation.EnumReflectionInformation;
import haxe.jvm.annotation.EnumValueReflectionInformation;

@EnumReflectionInformation(
    constructorNames = {"Some", "None"}
)
public abstract class Option extends Enum {
    None;

    protected Option(int index, String name) {
        super(index, name);
    }

    public static Option Some(Object v) {
        return new Option.Some(v);
    }

    public static Option[] values() {
        return new Option[]{None};
    }

    @EnumValueReflectionInformation(
        argumentNames = {"v"}
    )
    public static class Some extends Option {
        public final Object v;

        public Some(Object v) {
            super(0, "Some");
            this.v = v;
        }

        public boolean equals(Enum other) {
            if (!(other instanceof Option.Some)) {
                return false;
            } else {
                Option.Some other = (Option.Some)other;
                if (other.ordinal() != this.ordinal()) {
                    return false;
                } else {
                    return Jvm.maybeEnumEq(other.v, this.v);
                }
            }
        }
    }

    @EnumValueReflectionInformation(
        argumentNames = {}
    )
    public static class None extends Option {
        public None() {
            super(1, "None");
        }
    }
}

While this generates a certain amount of code, it's not particularly difficult to implement and takes care of various related problems like reflection and comparison.

SomeRanDev commented 1 year ago

Oh niiiice!! Thank you for this!

Yeahhh, polymorphism seems to be a pretty nice/powerful solution in these types of languages. And since things are stored and casted using sub-classes, C# struct members shouldn't be a problem. This is definitely the way to do things.

AndrewDRX commented 7 months ago

Not sure if there has been any additional work/thoughts on this since the last comment, but just weighing in w/ my own thoughts...

Somewhat similar to @Simn's response, here is Microsoft's documentation on creating an Enumeration class: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/enumeration-classes-over-enum-types

Which recommends it being defined like this:

public abstract class Enumeration : IComparable
{
    public string Name { get; private set; }

    public int Id { get; private set; }

    protected Enumeration(int id, string name) => (Id, Name) = (id, name);

    public override string ToString() => Name;

    public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
        typeof(T).GetFields(BindingFlags.Public |
                            BindingFlags.Static |
                            BindingFlags.DeclaredOnly)
                 .Select(f => f.GetValue(null))
                 .Cast<T>();

    public override bool Equals(object obj)
    {
        if (obj is not Enumeration otherValue)
        {
            return false;
        }

        var typeMatches = GetType().Equals(obj.GetType());
        var valueMatches = Id.Equals(otherValue.Id);

        return typeMatches && valueMatches;
    }

    public int CompareTo(object other) => Id.CompareTo(((Enumeration)other).Id);

    // Other utility methods ...
}

And can be extended like this:

public class CardType
    : Enumeration
{
    public static CardType Amex = new(1, nameof(Amex));
    public static CardType Visa = new(2, nameof(Visa));
    public static CardType MasterCard = new(3, nameof(MasterCard));

    public CardType(int id, string name)
        : base(id, name)
    {
    }
}

In the past, I have updated this recommendation a bit to use generics so that it can handle enumerations of data types other than int values.

Which is instead defined like this:

public abstract record Enumeration<T> : IComparable<Enumeration<T>>, IEquatable<Enumeration<T>>
    where T : IComparable<T>, IEquatable<T>
{
    public static implicit operator T(Enumeration<T> value) => value.Id;

    public static IEnumerable<TEnumeration> GetAll<TEnumeration>() where TEnumeration : Enumeration<T> =>
        typeof(TEnumeration)
            .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
            .Select(fieldInfo => fieldInfo.GetValue(null))
            .Cast<TEnumeration>();

    public static TEnumeration Parse<TEnumeration>(string name) where TEnumeration : Enumeration<T>
    {
        _ = TryParse<TEnumeration>(name, out var returnValue);
        return returnValue ?? throw new("Enumeration could not be parsed.");
    }

    public static bool TryParse<TEnumeration>(string name, out TEnumeration? value)
        where TEnumeration : Enumeration<T>
    {
        value = null;
        var maybe =
            typeof(TEnumeration)
                .GetField(name, BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)?
                .GetValue(null);
        if (maybe is null)
        {
            return false;
        }
        value = (TEnumeration)maybe;
        return true;
    }

    public T Id { get; private init; }

    public string Name { get; private init; }

    protected Enumeration(T id, string name) => (Id, Name) = (id, name);

    public int CompareTo(Enumeration<T>? other) => other is null ? 1 : Id.CompareTo(other.Id);

    public virtual bool Equals(Enumeration<T>? other) => other is not null && Id.Equals(other.Id);

    public override int GetHashCode() => Id.GetHashCode();

    public override string ToString() => Name;
}

And can instead be extended like this (for the int example):

public record CardType : Enumeration<int>
{
    public static readonly CardType Amex = new(1, nameof(Amex));

    public static readonly CardType Visa = new(2, nameof(Visa));

    public static readonly CardType MasterCard = new(3, nameof(MasterCard));

    protected CardType(int id, string name) : base(id, name) { }
}

So, it might not be a bad idea to target C# output that is somewhat structured like this.

Although, there are still some downsides to not using the native C# enum type, such as not being able to use it in a switch statement. E.g., this does not work:

var cardType = CardType.Amex;
string message;
switch (cardType)
{
    case CardType.Amex:
        message = "You have an AMEX.";
        break;
    case CardType.Visa:
        message = "You have a VISA.";
        break;
    default:
        message = "You have something else.";
        break;
}

But it can still sort of be used in switch expressions. E.g., this does work:

var cardType = CardType.Amex;
var message = true switch
{
    cardType.Equals(CardType.Amex) => "You have an AMEX.",
    cardType.Equals(CardType.Visa) => "You have a VISA.",
    _ => "You have something else."
}
Simn commented 7 months ago

Note that Haxe enums are actually algebraic data types, so you need to be able to store the associated values somehow.

Also, I think this is the first time I'm seeing this true switch syntax. That looks like someone was allergic to if statements...

jeremyfa commented 7 months ago

Yes, I'd stick to the idea suggested by @Simn here, as the primary goal is accuracy with Haxe, unless there are good reasons to use the version recommended by Microsoft docs (other than just they recommend it), like making code generation simpler when dealing with haxe enum values, comparisons expressions simpler etc...

Well, maybe @AndrewDRX suggestion is actually compatible with that idea, we'll see when implementing it

AndrewDRX commented 7 months ago

I thought the example I provided was quite similar and would be fairly compatible, so I was just adding it as extra supporting documentation 😅

But the reason to stick w/ the Microsoft docs is for consistency in how other C# projects would use these Enumeration types. E.g., having things like GetAll, CompareTo, Equals. And the second, enhanced version w/ Parse and TryParse to make it more similar to the standard C# enum types.

Ideally, a Haxe output should aim to generate something that is as intuitive and familiar to users of the target language as possible, rather than to users of Haxe.

If I were developing a C# project and had to choose between two libraries, I would certainly choose the one that didn't look foreign compared to the other C# code around it.

But w/ all that in mind, it is also ideal to have an output library that when used in one language is similar/consistent to using it in another language, which is going to sometimes be contradictory to my previous point.

But... I think I would lean toward preferring a library that is consistent w/ the target language rather than consistent w/ the same library output to a different language. Especially since there are certain efficiencies that can be achieved by following a design more closely to how the target language was intended to be written.


As for the true switch expression syntax, perhaps in my specific example an if-else statement might be better. I said it can "sort of" be used in switch expressions b/c it is a bit of a code smell.

However, there are certain cases where it might be more concise to use the switch expression, such as having an expression-bodied member. E.g.:

public static class Utilities
{
    public static string ErrorMessage(ISomeInterface value) =>
        true switch
        {
            true when value is Type1 => "Message 1",
            true when value is Type2 => "Message 2",
            true when value is Type3 => "Message 3",
            _ => "Generic Message"
        };
}

Although, still quite arguably a code smell.

Simn commented 7 months ago

I'm not sure what we're actually discussing here because it is my understanding that C# enums are a different kind of data. Unless I'm missing something, we cannot represent a type like our Option as C# enum:

enum Option<T> {
    None;
    Some(v:T);
}

The only part somewhat relevant is that Enumeration base type.

AndrewDRX commented 7 months ago

I still think it is quite similar, I just didn't elaborate on the Option<T> use case. It can be done like this (using the same Enumeration<T> as previously mentioned):

public record Option<T> : Enumeration<int> where T : IComparable<T>, IEquatable<T>
{
    public static readonly Option<T> None = new(1, nameof(None));

    public static readonly Option<T> Some = new(0, nameof(Some));

    protected Option(int id, string name) : base(id, name) { }
}

public record Some<T> : Option<T>, IComparable<Some<T>>, IEquatable<Some<T>> where T : IComparable<T>, IEquatable<T>
{
    public readonly T Value;

    public Some(T value) : base(0, nameof(Some)) => Value = value;

    public int CompareTo(Some<T>? other) => other is null ? 1 : Value.CompareTo(other.Value);

    public virtual bool Equals(Some<T>? other) => other is not null && Value.Equals(other.Value);

    public override int GetHashCode() => Value.GetHashCode();
}

And then used like this:

public static Option<string> TestOption(string? message = null) =>
    message is null ? Option<string>.None : new Some<string>(message);
var option1 = TestOption("SUCCESS");
var option2 = TestOption();
var option3 = TestOption("FAILURE");
var option4 = TestOption("SUCCESS");

/* Enum value match Option<T>.Some result */
_ = Option<string>.Some.Equals(option1); //true
_ = Option<string>.None.Equals(option1); //false

/* Enum value match Option<T>.None result */
_ = Option<string>.Some.Equals(option2); //false
_ = Option<string>.None.Equals(option2); //true

/* Actual value match */
_ = option1.Equals(option2); //false
_ = option1.Equals(option3); //false
_ = option1.Equals(option4); //true

Is this close to what you had in mind?

Simn commented 7 months ago

That's close, except that I don't think public static readonly Option<T> Some makes much sense because there's not a singular value for Some. It should instead be a function that calls new Some, and then we pretty much have what I originally posted.

AndrewDRX commented 7 months ago

I'm not sure that I agree on that part since leaving it as a static readonly field will allow that field to be used to check if any given Option<T> value is equal to the Option<T>.Some enumeration value without having to create a new Option<T> enumeration value to compare against every time (issue of efficiency).

Although, that comparison could still be done by checking if the value is Some<T>. But then the syntax for checking if it is Option<T>.None vs Option<T>.Some won't be the same (I.e., won't be able to use it exactly as Option<T>.Some.Equals(...) and Option<T>.None.Equals(...)).

So, I think it would be best to use the syntax new Some(...) to create the values rather than using a function Option<T>.Some(...).

But I was mostly trying to expand on your original response to provide similar C# logic that we could aim for as the output, as a rough guide, nothing definitive.

AndrewDRX commented 6 months ago

Although, thinking about it some more, we could achieve what both of us are thinking by using implicit cast operators to go between Func<T, Some<T>> and Some<T>.

Still given the same base Enumeration<T> as previously mentioned.

With Option<T> and Some<T> now defined like this:

public record Option<T> : Enumeration<int>, IComparable<Option<T>>, IEquatable<Option<T>>
    where T : IComparable<T>, IEquatable<T>
{
    public static implicit operator Option<T>(Func<T, Some<T>> _) => Some<T>.Default;

    public static readonly Option<T> None = new(1, nameof(None));

    public static readonly Func<T, Some<T>> Some = Some<T>.Default;

    public T Value { get; init; } = default!;

    protected Option(int id, string name) : base(id, name) { }

    public int CompareTo(Option<T>? other) => base.CompareTo(other);

    public virtual bool Equals(Option<T>? other) => base.Equals(other);

    public override int GetHashCode() => base.GetHashCode();
}

public sealed record Some<T> : Option<T>, IComparable<Some<T>>, IEquatable<Some<T>>
    where T : IComparable<T>, IEquatable<T>
{
    public static implicit operator Some<T>(Func<T, Some<T>> _) => Default;

    public static implicit operator Func<T, Some<T>>(Some<T> _) => (T value) => new(value);

    public static readonly Some<T> Default = new(default(T)!);

    public Some(T value) : base(0, nameof(Some)) => Value = value;

    public int CompareTo(Some<T>? other) => base.CompareTo(other);

    public bool Equals(Some<T>? other) => base.Equals(other);

    public override int GetHashCode() => base.GetHashCode();
}

And then used like this:

public static Option<string> TestOption(string? message = null) =>
    message is null ? Option<string>.None : Option<string>.Some(message);
var option1 = TestOption("SUCCESS");
var option2 = TestOption();
var option3 = TestOption("FAILURE");
var option4 = TestOption("SUCCESS");

/* Enum value match Option<T>.Some result */
_ = option1 == Option<string>.Some; //true
_ = option1 == Option<string>.None; //false

/* Enum value match Option<T>.None result */
_ = option2 == Option<string>.Some; //false
_ = option2 == Option<string>.None; //true

/* Actual value match */
_ = option1.Value == option2.Value; //false
_ = option1.Value == option3.Value; //false
_ = option1.Value == option4.Value; //true

So now Option<T>.Some can be invoked as a function type and retuned from method as a value type w/out any explicit casting.

With that, I am retiring from this thread for now 😅