Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for switch style methods. #48

Merged
merged 8 commits into from
Jul 2, 2019
Merged

Conversation

sgoodgrove
Copy link
Contributor

First of all, thank you for this project I was about to set about implementing my own type-safe enum pattern, when I found this project and see that it implements everything I was going to and more and in a very refined way.

I'm not entirely happy with the pattern matching switch statements available in C#. Therefore I'm proposing a fluent interface solution to the same task using a .When().Then() fluent interface in place of a regular switch/case statement.

I guess I should have opened an issue for this first, but I think code speaks well for developers. I'm happy if this doesn't get merged, but I think it might be a good discussion point.

Also, if you don't think this is suitable, I think it could be implemented as extension methods and go into a "contrib" namespace if you think it has some legs, but don't want it in the project.

I'm providing this snippet to demonstrate how I imagine it being used:

    [Fact]
    public void WhenMatchesLastParameterActionRuns()
    {
        var three = TestEnum.Three;

        var firstActionRun = false;
        var secondActionRun = false;
        var thirdActionRun = false;

        three
            .When(TestEnum.One).Then(() => firstActionRun = true)
            .When(TestEnum.Two).Then(() => secondActionRun = true)
            .When(TestEnum.One, TestEnum.Two, TestEnum.Three).Then(() => thirdActionRun = true);


        firstActionRun.Should().BeFalse();
        secondActionRun.Should().BeFalse();
        thirdActionRun.Should().BeTrue();
    }

I'm not entirely happy with the pattern matching switch statements available in C#. Therefore I'm proposing a fluent interface solution to the same task using a .When().Then() fluent interface in place of a regular switch/case statement.
@aalmada
Copy link
Contributor

aalmada commented Jul 1, 2019

I also like the fluent syntax but be aware of the performance implications in this case. Each call to When() makes a call to IEnumerable.Contains() which enumerates the collection until it finds the item or reachs the end of it. This means the collection will enumerated multiple times, unlike the switch clause. The use of lambdas also may result in variables capture.

src/SmartEnum/TValue.cs Outdated Show resolved Hide resolved
@@ -0,0 +1,21 @@
using System;

namespace Ardalis.SmartEnum.Core
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The namespace can be simply Ardalis.SmartEnum. This saves the users to have to add using clauses or Core for all interface uses.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put these classes into a "Core" namespace as they're not intended to be used by users. So when a user does using Ardalis.SmartEnum they don't get these somewhat cryptic implementation details in intellisense.

I'm happy to move them to Ardalis.SmartEnum if that's where you'd prefer them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think of that. That's a good idea to keep Core.

@@ -0,0 +1,22 @@
using System;

namespace Ardalis.SmartEnum.Core
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The namespace can be simply Ardalis.SmartEnum. This saves the users to have to add using clauses or Core for all interface uses.

@@ -0,0 +1,11 @@
using System;

namespace Ardalis.SmartEnum.Core
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The namespace can be simply Ardalis.SmartEnum. This saves the users to have to add using clauses or Core for all interface uses.

@aalmada
Copy link
Contributor

aalmada commented Jul 1, 2019

The entities Execute and DoNotExecute are classes so they are allocated on the heap, which results in work for the garbage collector.

Changing them to struct, by itself, doesn't fix this as the method When() returns an interface which would make the value type to be boxed and copied into the heap.

One possible solution is to delete DoNotExecute and IThen. Change Execute to the following:

    public readonly struct Execute<TEnum, TValue>
        where TEnum : SmartEnum<TEnum, TValue>
        where TValue : IEquatable<TValue>, IComparable<TValue>
    {
        private readonly bool enabled;
        private readonly SmartEnum<TEnum, TValue> smartEnum;

        public Execute(bool enabled, SmartEnum<TEnum, TValue> smartEnum)
        {
            this.enabled = enabled;
            this.smartEnum = smartEnum;
        }

        public SmartEnum<TEnum, TValue> Then(Action doThis)
        {
            if (enabled)
                doThis();

            return smartEnum;
        }
    }

And change When() to the following:

        public Execute<TEnum, TValue> When(params SmartEnum<TEnum, TValue>[] smartEnums) =>
            new Execute<TEnum, TValue>(smartEnums.Contains(this), this);

        public Execute<TEnum, TValue> When(IEnumerable<SmartEnum<TEnum, TValue>> smartEnums) =>
            new Execute<TEnum, TValue>(smartEnums.Contains(this), this);

@sgoodgrove
Copy link
Contributor Author

Yes, I'd not particularly paid attention to performance, but these are all valid concerns. I will restructure the code to work with structs and removing heap allocations.

@sgoodgrove
Copy link
Contributor Author

Ok. Let me know what you think of the changes I've made.

I've gotten rid of the interface and introduced a When struct and a Then struct. These are required to be able to thread through the stopEvaluating parameter. The stopEvaluating parameter prevents enum comparisons in the When() calls when a match has already been made.

I've also added in the Default() method.

I've additionally added in When(SmartEnum<TEnum, TValue> smartEnumWhen) to avoid the LINQ call to .Contains() when it isn't required.

At the moment I've left those two structs inside the ".Core" namespace, so that they doesn't appear in intellisense for users. I could achieve the same thing using an internal modifier, though I tend to avoid that. Let me know what you want to do with this and I'll adjust accordingly.

I've also added in a couple of tests around the Default() method.

@sgoodgrove
Copy link
Contributor Author

sgoodgrove commented Jul 1, 2019

Obviously what I really really want is for this to be "exhaustive" as well... I wonder if we could add another method called Exhaustive() that throws an exception if there has been no matches, but I don't know how useful that would be at runtime as opposed to compile time... but perhaps it could be seen as an opt-in alternative to Default().

e.g.

myEnum
    .When(MyEnum.First).Then(() => ...)
    .When(MyEnum.Second).Then(() => ...)
    .Exhaustive(); // throws an exception If myEnum is not matched

Clearly this is very different behaviour to compile time exhaustive checking.

@aalmada
Copy link
Contributor

aalmada commented Jul 1, 2019

Great! I'll have to check that later.

Copy link
Contributor

@aalmada aalmada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes you made are great! There's just a few more suggestions inline.

Please also add the following to the SwitchBenchmarks.cs file in the SmartEnum.Benchmarks project:

        [Benchmark]
        public TestSmartEnum SmartEnum_When()
        {
            TestSmartEnum result = null;

            TestSmartEnum.Ten
                .When(TestSmartEnum.One).Then(() => result = TestSmartEnum.One)
                .When(TestSmartEnum.Two).Then(() => result = TestSmartEnum.Two)
                .When(TestSmartEnum.Three).Then(() => result = TestSmartEnum.Three)
                .When(TestSmartEnum.Four).Then(() => result = TestSmartEnum.Four)
                .When(TestSmartEnum.Five).Then(() => result = TestSmartEnum.Five)
                .When(TestSmartEnum.Six).Then(() => result = TestSmartEnum.Six)
                .When(TestSmartEnum.Seven).Then(() => result = TestSmartEnum.Seven)
                .When(TestSmartEnum.Eight).Then(() => result = TestSmartEnum.Eight)
                .When(TestSmartEnum.Nine).Then(() => result = TestSmartEnum.Nine)
                .When(TestSmartEnum.Ten).Then(() => result = TestSmartEnum.Ten)
                .Default(() => throw new Exception());

            return result;
        }

This lets you compare performance between this and the other methods of "switching" a SmartEnum:

Method Mean Error StdDev Median Library Gen 0 Gen 1 Gen 2 Allocated
Enum_Switch 0.0056 ns 0.0100 ns 0.0252 ns 0.0000 ns Enum - - - -
Enum_IfEqualsMethodCascade 251.5596 ns 4.7387 ns 4.6540 ns 250.9543 ns Enum 0.2742 - - 432 B
Enum_IfEqualOperatorCascade 0.0023 ns 0.0080 ns 0.0071 ns 0.0000 ns Enum - - - -
SmartEnum_Switch 11.5462 ns 0.2117 ns 0.1980 ns 11.4451 ns SmartEnum - - - -
SmartEnum_SwitchPatternMatching 11.1354 ns 0.2059 ns 0.1607 ns 11.1402 ns SmartEnum - - - -
SmartEnum_IfCascade 49.4139 ns 0.6313 ns 0.5906 ns 49.3628 ns SmartEnum - - - -
SmartEnum_When 283.8668 ns 6.4530 ns 6.0361 ns 282.6747 ns SmartEnum 0.4220 - - 664 B

Notice that the fluent syntax is still a lot slower (283 ns vs. 11 ns) but still a good enough feature to be added.

The heap memory allocations (664 B) are due to the variable capture by the lambda expressions. If you change these to not access variables outside of its own scope, the allocations get down to zero and the performance increases considerably.

src/SmartEnum/Core/SmartEnumThen.cs Outdated Show resolved Hide resolved
src/SmartEnum/Core/SmartEnumWhen.cs Outdated Show resolved Hide resolved
src/SmartEnum/Core/SmartEnumWhen.cs Outdated Show resolved Hide resolved
src/SmartEnum/SmartEnum.cs Outdated Show resolved Hide resolved
@sgoodgrove
Copy link
Contributor Author

Yes. It is a shame about the performance.
I've been thinking hard about ways to shortcut the operations or avoid the closure, but I can't think of anything. I tried out a couple of alternatives, but they mostly just made the situation worse.

Change access level of When/Then constructors.
Change comparison from "==" to ".Equals()" to avoid null check.
@sgoodgrove
Copy link
Contributor Author

I've udpated with the latest suggestions.

Added When/Then benchmark method.
Change access level of When/Then constructors.
Change comparison from == to .Equals() to avoid null check.

@aalmada
Copy link
Contributor

aalmada commented Jul 2, 2019

@ardalis IMHO This is a nice feature to add. It needs your seal of approval. 😉

@ardalis
Copy link
Owner

ardalis commented Jul 2, 2019

Agreed, you two are both awesome, thank you!

@ardalis ardalis self-requested a review July 2, 2019 14:30
@ardalis
Copy link
Owner

ardalis commented Jul 2, 2019

Can you update the README with some instructions for usage (like what you have in the PR description) as part of this PR?

@sgoodgrove
Copy link
Contributor Author

sgoodgrove commented Jul 2, 2019

How about the following? Not sure if you want a perf warning front and centre in the readme.

Because of the limitations of pattern matching SmartEnum also provides a fluent interface to help create clean code:

testEnumVar
    .When(TestEnum.One).Then(() => ... )
    .When(TestEnum.Two).Then(() => ... )
    .When(TestEnum.Three).Then(() => ... )
    .Default( ... );

N.B. For performance critical code the fluent interface carries some overhead that you may wish to avoid. See the available benchmarks code for your use case.

@ardalis
Copy link
Owner

ardalis commented Jul 2, 2019

Sounds fine; small note with link to benchmarks is perfect.

@sgoodgrove
Copy link
Contributor Author

README.md updated

@ardalis ardalis merged commit d88f560 into ardalis:master Jul 2, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants