Home Forum RSS PGP Alerts Links (D) |
|
Branching on multiple conditions made readable Comment on this articleIf you need to branch on a set of conditions, if..then..else trees quickly begin to look real ugly. On top of that, they become nests of hard to find bugs and a nightmare for the maintenance programmer. A good example of this tragedy in action can be seen in Listing 1. I didn’t write this myself, but I have written any number of code sequences just as bad or worse, so I’m certainly no better than the author of this example. But looking at my own code, I don’t notice how hard it is to read. Don’t worry about the function calls to Trace() and Notify() or any of the others. I left them in so the code would look like code you encounter in real life, not because they have any particular significance to the topic of the article. Also note that in this case, more comments would almost certainly not help. Either they wouldn’t clarify much, or you wouldn’t be able to trust them anyway. The code is in Delphi, but I’m sure it wouldn’t be any easier to read if it had been in any other language. while True do begin bResult:= GetQueuedCompletionStatus( fhCompletionPort,ulTransf,Cardinal(pPH),POverlapped(pPIO),INFINITE); if (NOT bResult) and (pPIO=nil) then begin { function does not dequeue a completion packet } Trace('NO packet, Win32 Error Code: '+ IntToStr(GetLastError)); end else if (NOT bResult) and (pPIO<>nil) then begin { function dequeues a completion packet for a failed I/O operation } Trace('IO failed packet, Win32 Error Code: '+ IntToStr(GetLastError)); { Expect this for a socket that was killed before disconnecting } Notify(kNFY_DISCONNECT,pPH^.socket); DestroyPerIoData(pPIO); closesocket(pPH^.socket); FreeMem(pPH, SizeOf(TPerHandleData) ); end else if (ulTransf=0) then begin { EOF : connection closed } Notify(kNFY_DISCONNECT,pPH^.socket); DestroyPerIoData(pPIO); closesocket(pPH^.socket); FreeMem(pPH, SizeOf(TPerHandleData) ); end else begin { function dequeues a completion packet for a successful I/O operation! } if (pPIO^.ioType=opInput) then begin Trace(lcRecv,'socket['+ IntToStr(pPH^.socket) +'] Recv ' + IntToStr(ulTransf)); bResult:= ReadIoDataToMsg(pPH^.acMsg, pPIO^.ioBuf.buf, ulTransf, sMsg); DestroyPerIoData(pPIO); IssueRead(pPH,CreatePerIoData); end else if (pPIO^.ioType=opOutput) then begin Trace(lcSend,'socket['+ IntToStr(pPH^.socket) +'] Sent ' + IntToStr(ulTransf) +' ['+ PChar(pPIO^.ioBuf.buf) +']'); DestroyPerIoData(pPIO); end; end; end; This program code is impossible to follow or to verify; at least it is to me. I just sit there mumbling: “if bResult is True, but bytes transferred is zero, I end up at… ah… right, but if it’s False and pPIO is nil and … no… I mean not nil and transferred bytes is… eh…”. The root cause of this particularly evil example is that the GetQueuedCompletionStatus() Win32 API function has a really horrible interface. Just looking at the documentation of the return results, is enough to floor anyone: Return Values If the function dequeues a completion packet for a successful I/O operation from the completion port, the return value is nonzero. The function stores information in the variables pointed to by the lpNumberOfBytesTransferred, lpCompletionKey, and lpOverlapped parameters. If *lpOverlapped is NULL and the function does not dequeue a completion packet from the completion port, the return value is zero. The function does not store information in the variables pointed to by the lpNumberOfBytesTransferred and lpCompletionKey parameters. To get extended error information, call GetLastError. If the function did not dequeue a completion packet because the wait timed out, the error returned is WAIT_TIMEOUT. If *lpOverlapped is not NULL and the function dequeues a completion packet for a failed I/O operation from the completion port, the return value is zero. The function stores information in the variables pointed to by lpNumberOfBytesTransferred, lpCompletionKey, and lpOverlapped. To get extended error information, call GetLastError. What the text boils down to is that three different and almost independent returned values determine what actually happened in the call. The three return values are (using the naming of the parameters as in the Delphi example code):
Theoretically, these three variables can determine any one of eight possible states. Two of these states indicate that no packet was dequeued, two indicate that the I/O failed, one indicates a closed connection and one indicates a successful I/O. The remaining two states should never occur. In the code in Listing 1, it’s very difficult to see if all the eight states have been covered and if all the code is reachable. I really had to find a better design to make it more readable. Nested branches My first thought was to test only one condition per if/then/else, then have another two layers below each one: if bResult then begin if assigned(pPIO) then begin if ulTransf > 0 then begin …success I/O… end else begin …connection closed… end end else begin …illegal case… end end else begin if assigned(pPIO) then begin …failed I/O… end else begin …no dequeue… end end This method is even worse and will often cause a lot of code duplication. Just looking at it in outline makes me go all cross-eyed. The code example in Listing 1 is actually, believe it or not, the most readable way to use if/then/else constructs for branching on multiple conditions. Let’s forget about if/then/else then To get a grip on this situation, I’ll take a totally different approach. I start out with drawing up a decision table in a comment block to make sure I understand what is supposed to happen and to ensure that the future reader of the code also gets the idea (Listing 2). { a little helpful decision table for use in the case..of +---------------------------+---------------------------+ | pPIO = nil | pPIO <> nil | |-------------+-------------|-------------+-------------| | ulTransf = 0| ulTransf > 0| ulTransf = 0| ulTransf > 0| ----------------|-------------|-------------|-------------|-------------| bResult = False | No Dequeue | No Dequeue | Failed I/O | Failed I/O | ----------------|-------------|-------------|-------------|-------------| bResult = True | Illegal | Illegal | Conn Closed | Success I/O | ----------------+-------------+-------------+-------------+-------------+ } Calling the GetQueuedCompletionStatus() results in three Boolean conditions, so I need a three-dimensional table. Since that is real hard to do in a source file unless you’ve got holographic displays, I double the columns for transferred bytes instead. After I get the table just so, I translate it into a three-dimensional table in Delphi. If I have two conditions, it will be a two-dimensional table and if I’ve got four, it’ll be a four-dimensional table, of course. Each of the dimensions has only two values, False and True (in that order). In most other languages, I would have to use numeric indexes instead. The value type of the table is an enumeration corresponding to the contents in the table. Since this particular table has five different values, I create an enumeration with expressively named constants (Listing 3). type eCompRes = (eNoDequeue, eFailedIO, eIllegal, eConnClosed, eSuccessIO); Now I can create and initialize the decision table in code. If I define the table as a constant, it gets compiled into the executable and takes zero runtime (Listing 4). const aECR : array[False..True, False..True, False..True] of eCompRes = ( ((eNoDequeue, eNoDequeue ), (eFailedIO, eFailedIO)), ((eIllegal, eIllegal), (eConnClosed, eSuccessIO)) ); Now I put this declaration into my source file directly below the decision table, so that the reader can easily verify the correspondence between the two, making his or her eyes bounce up and down, as if watching a vertical game of tennis (Listing 5). const { a little helpful decision table for use in the case..of +---------------------------+---------------------------+ | pPIO = nil | pPIO <> nil | |-------------+-------------|-------------+-------------| | ulTransf = 0| ulTransf > 0| ulTransf = 0| ulTransf > 0| ----------------|-------------|-------------|-------------|-------------| bResult = False | No Dequeue | No Dequeue | Failed I/O | Failed I/O | ----------------|-------------|-------------|-------------|-------------| bResult = True | Illegal | Illegal | Conn Closed | Success I/O | ----------------+-------------+-------------+-------------+-------------+ } // translated into reality, this becomes: // array[--bResult--, ---pPIO----, -ulTransf--] of ... aECR : array[False..True, False..True, False..True] of eCompRes = ( ((eNoDequeue, eNoDequeue ), (eFailedIO, eFailedIO)), ((eIllegal, eIllegal), (eConnClosed, eSuccessIO)) ); Once I have the decision table, I can evaluate the three conditions, look up the enumeration result and branch, all in one fell swoop. As you can see, it becomes very easy to have a branch for the illegal (and unexpected) combinations as well (the eIllegal case). See Listing 6. while True do begin bResult:= GetQueuedCompletionStatus( fhCompletionPort, ulTransf, Cardinal(pPH), POverlapped(pPIO), INFINITE); // evaluate, lookup and branch case aECR[bResult, assigned(pPIO), ulTransf > 0] of eNoDequeue: begin Trace('NO packet, Win32 Error Code: '+ IntToStr(GetLastError)); end; eFailedIO: begin Trace('IO failed packet, Win32 Error Code: '+ IntToStr(GetLastError)); { Expect this for a socket that was killed before disconnecting Not freeing a IdTCPClient before killing process will do it. } Notify(kNFY_DISCONNECT, pPH^.socket); DestroyPerIoData(pPIO); closesocket(pPH^.socket); FreeMem(pPH, SizeOf(TPerHandleData)); end; eIllegal: // this should never happen assert(False, ‘Illegal results from GetQueuedCompletionStatus’); eConnClosed: begin Notify(kNFY_DISCONNECT, pPH^.socket); DestroyPerIoData(pPIO); closesocket(pPH^.socket); FreeMem(pPH, SizeOf(TPerHandleData)); end; eSuccessIO: begin case pPIO^.ioType of opInput : begin Trace(lcRecv, 'socket['+ IntToStr(pPH^.socket) +'] Recv '+ IntToStr(ulTransf)); bResult := ReadIoDataToMsg(pPH^.acMsg, pPIO^.ioBuf.buf, ulTransf, sMsg); DestroyPerIoData(pPIO); if (bResult) then begin Notify(kNFY_PING); // TODO: do something with the string HERE! end; IssueRead(pPH, CreatePerIoData); end; opOutput: begin Trace(lcSend, 'socket['+ IntToStr(pPH^.socket) + '] Sent ' + IntToStr(ulTransf) + ' [' + PChar(pPIO^.ioBuf.buf) + ']'); DestroyPerIoData(pPIO); end; else Trace('*** Illegal pPIO.ioType'); end; // case pPIO^.ioType end; // eSuccessIO end; // case aECR[...] end; // while True GoodNess abounds Now, this, dear reader, gives the maintenance programmer a fighting chance to see what I’m actually doing. It also forces me to cover all cases explicitly, but without duplicating code. And, on top of that, it probably executes faster. It simply overflows with GoodNess®. There’s just one possible runtime disadvantage and that is that all conditions used to index into the array are always evaluated. If that is unacceptable, I divide the case tree into nested trees, each based on its own decision table. (As an illustration, the branching on pPIO^.ioType under the eSuccessIO case is nested which doesn’t really make it any more difficult to follow the processing flow. The reason it is nested is that it can’t be evaluated at all unless pPIO is assigned a value.) The principle of multiple branch conditions should be applicable to any language that allows multidimensional arrays, enumerations and switch statements. Delphi is particularly elegant in that it allows me to index arrays using Booleans, but C++ or C# would need only slightly more code to achieve the same effect, while maintaining the same level of readability. |
TOP |