Quick feedback
and the overall collection state depends on the state of each of these objects
The criteria for each collection state (based on the collection's element's states) has to be defined somewhere. You can't get around that.
Currently, the code is written in the form of several if-else blocks, however, it seems ugly and non-extensible.
By that, I mean, tomorrow if additional object states are added, I would end up adding several more flags and if-conditions, making the code difficult to read and maintain.
It can be cleaned up (I added a suggestion below). This improves the readability and maintainability.
However, it's important to understand that while you can avoid the if
chain in and of itself, you cannot avoid having to define the criteria.
isOkay
, isWarning
, isError
These names are not fitting of the variable. Especially since all three could be true at the same time. A more fitting name would be containsError
(and similar).
Reviewing your code
internal enum CollectionState
{
Error,
AllWarnings,
AllOkay,
OkayAndWarnings
}
An enum, at least by itself, is not a good solution here. Each member of the enum has its own criteria for being valid or not. However, the criteria are not linked to the value themselves, which makes it less than ideal to find out what the criteria of a given state are.
if (isError)
{
return CollectionState.Error;
}
else if(!isOkay && isWarning)
{
return CollectionState.AllWarnings;
}
else if (isOkay && !isWarning)
{
return CollectionState.AllOkay;
}
else //if(isOkay && isWarning)
{
return CollectionState.OkayAndWarnings;
}
Here, you've listed the criteria. You've already identified that the if
chain is an ugly solution; which it is.
It's important to notice that the combined criteria of all collection states should cover all cases but also have no overlap.
As to covering all cases; you have no guarantee that this is being done correctly. You're ending the chain on an else
, which means that anything that hasn't matched yet will be given this default collectionstate. This might be what you want, but I can see a similar argument that you want to avoid this and would rather be alerted of not finding an explicit match (it helps with future maintainability).
As to preventing overlap; that is currently the case. But the way it's implemented now leaves gaps as to enforcing this behavior. If an issue arises where two criteria can have overlap (e.g. NoErrors
and AllWarnings
), that may be initially hidden because the if
chain will return the first found match and ignore the second. If the chain gets reshuffled at some point, you're suddenly going to be faced with unexpected behavior (as the second criteria may nog be returned as the first found match).
So I would like to focus on improving these things:
- A clear connection between a collection state and its criteria.
- Minimizing the needed code footprint for adding new statecollections
- Alerting developers of overlapping criteria (which is a logical problem)
- Optional - Alerting developers of a situation which yields no matches (which can be a problem)
My proposed solution
- A clear connection between a collection state and its criteria.
public class CollectionStateDefinition
{
public CollectionState State { get; set; }
public Func<IEnumerable<MyClass>, bool> Criteria { get; set; }
public CollectionStateDefinition(CollectionState state, Func<IEnumerable<MyClass>, bool> criteria)
{
this.State = state;
this.Criteria = criteria;
}
}
public static List<CollectionStateDefinition> CollectionStateDefinitions = new List<CollectionStateDefinition>()
{
new CollectionStateDefinition(CollectionState.Error,
coll => coll.Any(myObject => myObject.State == State.Error)),
new CollectionStateDefinition(CollectionState.AllWarnings,
coll => coll.All(myObject => myObject.State == State.Warning)),
new CollectionStateDefinition(CollectionState.AllOkay,
coll => coll.All(myObject => myObject.State == State.Okay))
}
This creates a clear mapping between a collection state and the criteria for matching the collection state.
I omitted OkayAndWarnings
from the first example, because this is a bit of a special case:
new CollectionStateDefinition(CollectionState.OkayAndWarnings,
coll =>
!coll.Any(myObject => myObject.State == State.Error)
&& coll.Any(myObject => myObject.State == State.Warning)
&& coll.Any(myObject => myObject.State == State.Okay)
)
These criteria are only met if:
- No errors are present
- At least one Warning exists
- At least one Okay exists
Another mention here is that you could've used a different approach here, but I specifically chose not to:
- Using tuples instead of
CollectionStateDefinition
. This would work and would preclude the need for a custom built class.
- I don't like unnamed tuples, as they hinder readability.
- Named tuples would be more readably. There's nothing wrong with taking this approach, I simply prefer building an explicit class. Especially since you're already considering future extensability.
- I get the feeling that some people would err towards a
Dictionary<CollectionState,Func<..>>
here. While I can see why (because every CollectionState has one criteria function), I wouldn't use it here, because this would force us to find the key by looking for the value, which is the opposite of how a Dictionary is supposed to work. It's possible to do so on a technical level, but it creates unnecessarily contrived code to do so.
- Minimizing the needed code footprint for adding new statecollections
This is achieved here. All you need to do is add an extra StateCollectionDefinition
to the list.
I haven't shown the method that looks for matching criteria yet, but it will not require any changes when a new entry is added to the list.
- Alerting developers of overlapping criteria (which is a logical problem)
public static CollectionState GetCollectionState(IEnumerable<MyClass> myList)
{
var matchingCriteria = CollectionStateDefinitions.Where(csd => csd.Criteria.Invoke(myList));
if(!matchingCriteria.Any())
throw new Exception("There is no matching state collection!");
if(matchingCriteria.Count() > 1)
throw new Exception("There is more than one matching state collection!");
return matchingCriteria.Single().State;
}
This is a barebones example. It would be nice if you could say which criteria were matched when you find more than one match. Over all, the exceptions can be improved, but I kept it simple for the sake of example.
- Optional - Alerting developers of a situation which yields no matches (which can be a problem)
This is already achieved in the above method.
If you want to provide a default value instead, you can easily change the code:
if(!matchingCriteria.Any())
return CollectionState.OkayAndWarnings;
You could also do both, e.g. log a message (to a log file) and return a default value anyway.
Footnote
Personally, I wouldn't use OkayAndWarnings
. I suspect you've currently implemented OkayAndWarnings
specifically because it's supposed to be a default for when the other criteria don't match; rather than OkayAndWarnings
being a useful classification in and of itself.
I would tend to use a system similar to:
- Show error state if errors exist.
- Show warning state if warnings exist (and no errors).
- Show okay state if there are no warnings or errors.
This would actually preclude the need for a StateCollection
enum. You could reuse your same State enum, with a method that takes a IEnumerable<MyClass>
and returns a State
, where the logic basically follows the bullet points I just mentioned.
However, in such a case, if
is actually a decent enough approach since the example is so simple.
My proposed solution is more complex because it's built to work for any number of states and catches several fringe issues (no or more matches).
MyClass
to the MCVE variant, as his question isn't "review all my code" but rather asking for a review on a particular approach/algorithm. \$\endgroup\$