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

Provides generic version of LifetimeEntryManager #6212

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

Frederisk
Copy link

@Frederisk Frederisk commented Mar 11, 2024

I noticed that the LifetimeEntryManager class is not generic. This will cause me to have to cast repeatedly if I overload the LifetimeEntry class:

public class MyEntry : LiftTimeEntry {}
// ...
private readonly LifetimeEntryManager manager = new();
// ...
this.manager.AddEntry(new MyEntry());
this.manager.EntryBecameAlive += (LifetimeEntry e) => {
  var my = (MyEntry)e;
}
this.manager.EntryBecameDead += (LifetimeEntry e) => {
  var my = (MyEntry)e;
}

I thought maybe we should provide a generic version (LifetimeEntryManager) to avoid such excessive casts. In addition, I noticed that osu! also casts classes like this.

Ultimately, I settled on using CRTP as a trick to accomplish this. However, due to limitations of C# features, some functions may not be implemented perfectly. And for the sake of compatibility, I still provide a non-generic version to avoid some breaking-changes.

@pull-request-size pull-request-size bot added size/L and removed size/M labels Mar 12, 2024
@Frederisk
Copy link
Author

Frederisk commented Mar 12, 2024

I wrote a rough benchmark, and it seems that the generic version does achieve the same effect while having higher performance. Of course this test is very simple, if anyone has better ideas, a more comprehensive test is also welcome.

BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.4046/22H2/2022Update)
Intel Core i7-8650U CPU 1.90GHz (Kaby Lake R), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
  [Host]     : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
| Method     | Mean     | Error     | StdDev    |
|----------- |---------:|----------:|----------:|
| NormalAdd  | 2.309 μs | 0.0440 μs | 0.0411 μs |
| GenericAdd | 2.033 μs | 0.0286 μs | 0.0224 μs |

@bdach
Copy link
Collaborator

bdach commented Mar 12, 2024

Performance is not a factor here. The "gains" above are negligible at best, if not straight up just measurement error.

@Frederisk Frederisk marked this pull request as ready for review March 12, 2024 08:58
@Frederisk
Copy link
Author

Another question is whether we need to replace LifetimeEntryManager in some existing classes with generic versions? For example, LifetimeManagementContainer or PooledDrawableWithLifetimeContainer in osu!.

@bdach
Copy link
Collaborator

bdach commented Mar 12, 2024

This PR should include a preview of what the required game-side changes are after merging this.

@Frederisk
Copy link
Author

Frederisk commented Mar 12, 2024

Performance is not a factor here. The "gains" above are negligible at best, if not straight up just measurement error.

Yeah, just making sure I don't run into new performance issues.

This PR should include a preview of what the required game-side changes are after merging this.

In theory, I provide a non-generic version, so existing code doesn't need any modifications and still works as usual. In other words, it is feasible for other content to remain unchanged after the PR is merged.

And if modification is needed, for classes such as LifetimeManagementContainer, we need:

- private readonly LifetimeEntryManager manager = new();
+ private readonly LifetimeEntryManager<DrawableLifetimeEntry> manager = new();
...
- private class DrawableLifetimeEntry : LifetimeEntry, IDisposable {
+ private class DrawableLifetimeEntry : LifetimeEntry<DrawableLifetimeEntry>, IDisposable {
...
}
...

and remove all redundant type conversions.

The process involving PooledDrawableWithLifetimeContainer, another layer of generics, is generally similar, except:

public abstract partial class PooledDrawableWithLifetimeContainer<TEntry, TDrawable> : CompositeDrawable
- where TEntry : LifetimeEntry
+ where TEntry : LifetimeEntry<TEntry>
 where TDrawable : Drawable {
...
}

@bdach
Copy link
Collaborator

bdach commented Mar 13, 2024

I would expect to see a preview of how converting the game-side usages to use the generics would look like, to evaluate if this implementation is the code quality improvement it claims to be.

API surface changes must always be judged by the usage code.

@Frederisk
Copy link
Author

I don't quite understand how I should provide a preview, do I need to open another PR in the repo of osu!?

@bdach
Copy link
Collaborator

bdach commented Mar 13, 2024

A link to an un-PR'd branch or a patch would also do.

@Frederisk
Copy link
Author

Frederisk commented Mar 13, 2024

I tried to modify PooledDrawableWithLifetimeContainer in osu! to the expected generic type as an example. Because of the nested relationships between many classes, modifications can appear piecemeal.

Finding other classes that need to be modified (including those in o!f and osu!) and modifying them will take me a lot of time, so it may take a while before I propose a proper complete PR. Or I could try submitting a series of PRs to modify these classes one by one.

Comment on lines 21 to 22
/// <typeparam name="TDerived">The implemented class itself. Used to provide derived category information for base categories using the Curiously Recurring Template Pattern(CRTP).</typeparam>
public abstract class LifetimeEntry<TDerived> where TDerived : LifetimeEntry<TDerived>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nooooooooooooooooooooooooope. Nope nope nope nope. I'm not having anything that the cpp people invented. Especially not with a name like "curiously recurring". I don't even follow what this is supposed to be doing.

I will not accept this sort of arcane construction here. If this makes-or-breaks the entire series then it might as well be closed right now.

Copy link
Author

Choose a reason for hiding this comment

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

I will not accept this sort of arcane construction here.

Sadly, this is necessary. Manager needs to use this type argument to get the actual type of the field.

I even thought this was a fairly common way of writing modern C#. I just want to clarify here what the real name of this mode is.😕

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm honestly having a difficult time accepting that linked article if it's giving IEquatable<T> as an example of this. IEquatable<T> is nowhere near as difficult to parse! It's not doing this IEquatable<T> where T : IEquatable<T> brain twister thing in any class signature. Its methods accept T.

Copy link
Author

Choose a reason for hiding this comment

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

I just spent some time searching for something I myself learned about this many years ago. Luckily, I found it and I thought this article might be a good explanation.

Comment on lines +83 to +100
// Due to the type constraints of C#, we cannot declare `LifetimeEntry<T> where T = LifetimeEntry<T>` to limit the type of `this` to be `T`. But when used correctly, this will always be the case.
// aka. We can't stop anyone from writing code like this:
// ```csharp
// public class MyEntry : LifetimeEntry<MyEntry> { }
// LifetimeEntry<MyEntry> foo = new();
// ```
// We prevent users from inadvertently writing such code by declaring `LifetimeEntry<T>` as `abstract`, but we cannot prevent it completely.
// ```csharp
// public class NewEntry<T> : LifetimeEntry<T> where T : LifetimeEntry<T> { }
// NewEntry<MyEntry> a = new();
// ```
// Happily, however, the compiler correctly prevents code like this from compiling. This is also enough to deter users from writing incorrect code:
// ```csharp
// public class ErrorEntry : NewEntry<MyEntry> { }
// LifetimeEntryManager<NewEntry<MyEntry>> error1 = new(); // Compiler error.
// LifetimeEntryManager<ErrorEntry> error2 = new(); // Compiler error.
// ```
RequestLifetimeUpdate?.Invoke((TDerived)this);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Case in point for the above diatribe. If API correctness of a method requires this sort of preamble to convince readers that the API surface is safe, the whole thing is overdone.

Copy link
Author

Choose a reason for hiding this comment

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

Basically, it's the same thing, I'm just trying to illustrate why we still can't get away from typecasting. I simply think that a more detailed explanation will help myself or other users understand the design logic clearly.

These comments are not actually necessary, since I don't really need any additional constraints or commitments. These are written within the design scope of the compiler and I'm not trying to break anything.

I tried to clarify my thinking through comments, but now it seems like this is overkill?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not even sure what that is attempting to explain. Why is the first snippet "unwanted"? It looks like perfectly reasonable code. What is the second snippet supposed to be? What are NewEntry and MyEntry?

Copy link
Author

@Frederisk Frederisk Mar 26, 2024

Choose a reason for hiding this comment

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

I'm not even sure what that is attempting to explain. Why is the first snippet "unwanted"?

Because if the user writes code like that, the cast here will throw an exception.

LifetimeEntry<MyEntry>
// TDerived is "MyEntry" here.
// this.GetType() == LifetimeEntry
...
(MyEntry)this // throw an Exception: A base type cannot be cast to a derived type.

Also notice that I declared this class as an abstract class to prevent users from doing something wrong like this.

Copy link
Author

Choose a reason for hiding this comment

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

However, this subtle error will be exposed through the following compilation errors when writing. I just want to make this clear. After all, this recursive form of generics can be a bit confusing to understand how the compiler will handle it.

Copy link
Author

@Frederisk Frederisk Mar 26, 2024

Choose a reason for hiding this comment

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

What is the second snippet supposed to be? What are NewEntry and MyEntry?

Assumptions about an extreme situation. Although I declared this class as abstract class. But like all abstract classes there are problems that may be encountered. You can't prevent users from implementing an abstract class without taking any meaningful action. This is basically like:

abstract class A {
  // some thing here.
}
class B : A { } // class B is the same as A, but it's no longer abstract!

This makes it possible for users to write illogical code.

Then in the third code block, I actually illustrate that even if this happens, the compiler will correctly prevent the user from doing this.

@Frederisk
Copy link
Author

Frederisk commented Apr 3, 2024

I understand that CRTP has caused confusion for someone, so please allow me to provide some clarification here:

What is CRTP: CRTP is a trick but not a hack. It is used here to pass the Entry type information to the Manager. CRTP is not unique to C/C++, it can also be implemented correctly in many languages ​​​​that provide generics, such as Java, Kotlin, and C#. CRTP is just an idiom here. It's unquestionably legal in the C# language because obviously we don't need any compiler magic and it won't produce any compiler warnings or errors.

Why CRTP: When implementing a generic Manager in a normal way, you will encounter the following problems (I'll explain using a simplified model):

public class Manager<T> where T : Entry {
  protected T? item;
  public void Foo(T entry) {
    entry.Do += (sender) => {
      this.item = sender;
    }
  }
}

public class Entry {
  public event Action<Entry>? Do;
  public void Bar() {
    Do?.Invoke(this);
  }
}

Then obviously, sender cannot be safely converted to T. And on the other hand, even if I implemented MyEntry, it would still be of the wrong type for Do and would require us to use casts to maintain it unsafely.

public class MyEntry : Entry { }
...
MyEntry.Do.GetType() == typeof(Action<Entry>); // But we prefer it to be Action<MyEntry>

Therefore, we need a type parameter to pass the type of the current class to the Entry class's own field and the Manager that uses this class:

public class Manager<T> where T : EntryBase<T> {
    protected T? item;
    public void Foo(T entry) {
        entry.Do += (sender) => {
            this.item = sender;
        };
    }
}

public abstract class EntryBase<T> where T : EntryBase<T> {
    public event Action<T>? Do;
    public void Bar() {
        Do?.Invoke((T)this);
    }
}

public class Entry : EntryBase<Entry> { }

Now, everything works fine, our Do can have the correct type, and the sender no longer needs any casts.

Of course, we can notice that there is a type cast in Do?.Invoke. But this is safe, and I stated in the comments that the compiler properly prevents users from breaking the safety of the cast.

Maybe you are uncomfortable with such lengthy comments, because it seems to imply that I am using comments to maintain the normal operation of a class. However, what I need to explain is that those comments are just for those who want to understand the design details. After all, I have to admit that many higher-level techniques are used internally in this class, and not everyone can easily understand them. The intent within the code.

For users of this class, they only need to follow this rule:

public class MyEntry : EntryBase<MyEntry> {
  // something here
}
...
var manager = new Manager<MyEntry>;

And the compiler will correctly block incorrect code.

BTW, English is not my first language, so if there is any ambiguity in my explanation please point it out and I will rephrase it.

@Frederisk
Copy link
Author

Frederisk commented Apr 3, 2024

But the comments above just now reminded me that maybe I should give LifetimeEntry<TDerived> a new name, such as LifetimeEntryBase<TDerived>, to make the code clearer. Or any other ideas?🤔

@bdach
Copy link
Collaborator

bdach commented Apr 3, 2024

Let me be very clear on this:

  • This PR series wants to remove some casting when using LifetimeEntryManager. Generally, a cast takes five seconds to parse mentally.
  • What is offered instead is a generic-based "trick" which invariably takes more than five seconds to parse mentally, and which requires implementors to know about it and understand how it works.

I do not find that an acceptable tradeoff at this time regardless of how many words of explanation are thrown at me. I am not interested in reviewing this series any further. If anyone else is they are free to do so but I will not participate in further discussions on this matter.

@Frederisk
Copy link
Author

Frederisk commented Apr 3, 2024

Well, I respect your opinion. I regret that we did not reach a consensus.🥲

But in fact, users of these two classes don't even need to understand how they work. They only need to inherit from the generic class. So I don't think this invariably requires users to spend time mentally parsing it.

Also, the non-generic version of these classes is still available without any change in functionality.

@Frederisk
Copy link
Author

As a fact, CRTP is a trick that is already used in .NET.

For example: IAdditionOperators:

public interface IAdditionOperators<TSelf,TOther,TResult> where TSelf : IAdditionOperators<TSelf,TOther,TResult>

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

Successfully merging this pull request may close these issues.

3 participants