Expressing 2x2 Logic Grid in Code Efficiently

2019-01-25 16:56发布

In an event handler I'm responding to the change of a value. I have access to the old value and the new value and want to do certain things depending on what the change is.

Each different outcome will do some combination of actions/functions X, Y, or Z. Z accepts a parameter between -1 and 1. Order of performing these is not important.

Look at the following logic grid. The old value is the leftmost column of labels, and the new value is the top row of labels:

          New:
          0          !=0
          --------   -------
Old:  0 | nothing    Y, Z(1)
    !=0 | X, Z(-1)   X, Y    -- Z(0) is okay but not required for this quadrant

What would be a good way to represent this?

I'm working in C# but will accept answers in any language since it's not really a language question—I can translate whatever.

Example:

if (oldvalue == 0 && newvalue == 0) return;
if (oldvalue != 0) X();
if (newvalue != 0) Y();
Z(oldvalue != 0 ? -1 : 0 + newvalue != 0 ? 1 : 0);

I suppose that looks pretty good, but there are other ways it could be done.

int which = (oldvalue == 0 ? 0 : 1) + (newvalue == 0 ? 0 : 2)

switch (which) {
   case 1:
      X(); Z(-1);
      break;
   case 2:
      Y(); Z(1);
      break;
   case 3:
      X(); Y();
      break;
}

This is actually a slightly simpler case than what I'm dealing with. In the case that oldvalue and newvalue are nonzero and equal to each other, treat newvalue as if it was 0.

Feel free to answer as given or with this additional constraint. There's still a little bit more but I think it's too much to start off with. If things seem interesting afterward, I'll present the rest either here or in a new question.

I guess I'm asking the question because I end up with these logic grid things often, and they aren't always 2x2, sometimes they're a bit bigger. It's nice to notice that I can handle some responses with entire "stripes" like noticing that X is done every time the oldvalue != 0, but it seems like I'm starting to run into a pattern that begs for some expressive logic to handle it more generally instead of laboriously turning it into if then else statements. I mean, it would be really cool if I could provide a sort of grid of logic and let the compiler figure out the best way to handle it.

Just doing some wild brainstorming:

Conditions:
oldvalue == 0 ? 0 : 1
newvalue == 0 ? 0 : 2

Actions:
X = {false, true, false, true}
Y = {false, false, true, true}
Z(-1) = true where condition = 1
Z(1) = true where condition = 2

What are your ideas? I'll reward any material involvement at all.

3条回答
2楼-- · 2019-01-25 17:32

You can actually use a multidimentional array to shortcut your test and branch process. If the result of the lookup is not a fixed value, but is instead some sort of work that needs to be done, you can make the array an array of delegate and populate it with methods or executable code (lambdas).

Like this:

private void Y() { }
private void X() {}
private void Z(int n) {}

private void example(int oldvalue, int newvalue)
{
    var array = new Action[2, 2];
    array[0, 0] = () => { };
    array[0, 1] = () => { Y(); Z(1); };
    array[1, 0] = () => { X(); Z(-1); };
    array[1, 1] = () => { X(); Y(); };

    // invoke
    array[oldvalue == 0 ? 0 : 1, newvalue == 0 ? 0 : 1]();
}

You can also initialize the state array using an inline initializer, but I find breaking out the cell assignments in explicit statements much easier to read and keep track of what goes where.

Initialization of the array could be done in advance, perhaps in the constructor of the class that contains this example function. Execution in the example function would then be reduced to

  1. evaluate oldvalue == 0
  2. evaluate newvalue == 0
  3. index into the array to retrieve the delegate
  4. execute the delegate to perform the action

This technique results in very, very little execution overhead (fast), scales well to arbitrary numbers of dimensions, and preserves the correlation between the code and your logic table which should make it much easier to understand and modify later compared to jumbles of if statements all over the place.

查看更多
Lonely孤独者°
3楼-- · 2019-01-25 17:34

Let's look at your problem from another point of view. When designing a body of code, I try to apply the following principle:

  1. Make it correct.
  2. Make it clear.
  3. Make it concise.
  4. Make it fast. ... in that order.

All of these, to one extent or another, are subjective. However, reasonable people tend to find common ground - and there's often more agreement about their opposites. But that aside...

The first priority here is making sure that your code will work correctly. Clearly, there are multiple implementation that achieve this - but I would also add that it's important that it be easy to demonstrate that the implementation is correct. One way of achieving this is to make the code read more like the spec (more on this later).

The second priority is to make sure that in the future, when a developer (including the original author!) looks at this code they'll be able to understand what it's doing right away. The more sophisticated (read: fancy) the implementation, the harder it is for a developer to immediately understand what the code is doing.

The third priority - short, concise code, is one that partially stands in opposition to the first two. A desire to make the code more concise, may lead you to use more complex constructs than are actually necessary to solve the problem. While it's important to keep the code short, we shouldn't do this by making it unintelligibly dense.

The last priority - performance - only matters when it matters. By this, I mean that you shouldn't complicate the implementation from the point of view of performance unless you've performed profiling and identified it as bottleneck in your system.

So now that we've looked at the principles that should drive our decisions, let's apply them to the problem at hand. You've provided a very clear specification of how the code is supposed to behave. Let's try to adhere to them:

void YourMethod( int oldValue, int newValue )
{
    bool oldValueNonZero = oldValue != 0;
    bool newValueNonZero = newValue != 0;

    if( oldValueNonZero ) { X(); }
    if( newValueNonZero ) { Y(); }
    if( oldValueNonZero && newValueNonZero ) { Z(); }
}

So why do I like this particular implementation. Let's break it down.

First, note that I've chosen to create temporary boolean to capture the result of testing the old/new value for whether they are nonzero. By capturing these values, I avoid performing the calculation more than once, and I also make the code more readable (see below).

Second, by choosing the descriptive names oldValueNonZero and newValueNonZero I'm making the implementation clearly indicate my expectations. This both improves the readability of the code and clearly conveys my intent to future developers who have to read it.

Third, note that the body of the if() test is wrapped in { and } brackets - this helps reduce that chance that future changes to the implementation will break the behavior - by accidentally including a new case, for instance. Using single-line ifs is a recipe for future problems.

Finally, I don't try to short-circuit the comparison and exit the function early. If performance were extremely important, an early exit may be useful. But otherwise, it makes it easier to understand the behavior of a method if there's only one exit point(1).

Does this code do what the spec says? I believe so.

Is it easy to read and understand. At least to my eyes, I would say yes.

Is this code the most compact or sophisticated way of short-circuiting the logic? Almost certainly not ... but it's other qualities more than make up for that, in my opinion.

Whether you like this particular structure of code or not is, to some extent, a matter of taste and style. But I hope that the principles I've laid out about how I chose to organize it may help you make such decisions in the future.

You've indicated that you sometimes run into similar "logic-grid" problems, but ones where the number of cases are more numerous. These kinds of problems can become complicated for two separate reasons:

  1. The number of values that the parameters can take on increase - they can take on the general form MxN.
  2. The number of dimensions increase - in other words, there are more variables to include in the rules: MxNxOxP...xZ.

One generalized solution to the problem (as another response indicates), is to encode the solution as multidimensional matrix - and define a set of actions for each case. However, it's entirely possible for the rules to overlap - and it's probably desirable to collapse equivalent cases together, for simplicity.

My response to dealing with the general case is ... that it depends. If the conditions can be reduced to some very small number of cases, than imperative if/else logic may still be the best way to solve the problem. If the number of conditions are very large, than it may make sense to use a declarative approach, in which you use some kind of lookup table or matrix to encode the cases.

1> - One common exception to the principle of only having a single exit point from a method is for preconditions. It's cleaner to avoid nesting and inverted logic by first checking all/any preconditions, and failing (exiting) the method if they are violated.

查看更多
手持菜刀,她持情操
4楼-- · 2019-01-25 17:35

Extra Credit Solution

This is a very interesting topic with some great insight from the contributors. I think your question's been answered so I'll focus on the extra credit. The easiest thing to do is update your table and apply it to whichever solution you like best.

Let's phrase the problem in a way that the conditions for each column are completely described. We would perform an action from the first column if the new value is equal to either zero or the old value. We would perform an action from the second column if the new value is nonzero and not equal to the old value.

          New == 0 || New == Old    New != 0 && New != Old
          ----------------------   ------------------------
Old == 0 | nothing                  Y, Z(1)
Old != 0 | X, Z(-1)                 X, Y    -- Z(0) is okay but not required for this quadrant

So in dthorpe's solution we would replace newValue == 0 with newValue == 0 || newValue == oldValue :

private void example(int oldvalue, int newvalue)
{
    var array = new Action[2, 2];
    array[0, 0] = () => { };
    array[0, 1] = () => { Y(); Z(1); };
    array[1, 0] = () => { X(); Z(-1); };
    array[1, 1] = () => { X(); Y(); };

    array[oldvalue == 0 ? 0 : 1, newValue == 0 || newValue == oldValue ? 0 : 1]();
}

In LBushkin's solution we would replace newValue != 0 with newValue != 0 && newValue != oldValue and update the respective variable name to maintain readability. I'm also going to bastardize the code bit:

void YourMethod( int oldValue, int newValue )
{
    bool oldValueNonZero = oldValue != 0;
    bool newValueDifferentAndNonZero = newValue != 0 && newValue != oldValue;
    int zCondition = 0;

    if( oldValueNonZero ) { X(); zCondition--;}
    if( newValueDifferentAndNonZero ) { Y(); zCondition++;}
    if( zCondition != 0 ) { Z(zCondition); }
}

Tada!

查看更多
登录 后发表回答