Saturday, August 30, 2014

Refactoring for Correctness, Robustness and Maintainability

Being faced with the task of maintaining legacy code can be daunting.  Legacy code is often poorly structured, sparsely commented and rife with bugs.  However, merely restructuring the code can expose logical inconsistencies.  These inconsistencies often reflect a lack of precision in the implementation, or design decisions that were never addressed before coding began.  Resolving such inconsistencies often makes large numbers of bugs simply disappear.

I have worked on large, inscrutable legacy code bases for many years.  Time after time, applying the simple transformations catalogued in this blog has allowed me to correct long-standing weaknesses, and improve both the internal and observable quality of the code.

I treat the various transformations described here as a toolbox, each tool to be applied as appropriate to improve my understanding of the code while also cleaning it up.  In "Bitter Java", [ ] describes software "smells" and the tools he applies to improve the code.  My approach is similar: each technique is mostly orthogonal to the others.  Applying any one of them can improve the quality of the code in some way.  Applying many in series can make the revised code completely unrecognizable when compared to the original, even though all of the changes are merely formal and almost purely formulaic.

Certain assumptions underlie this approach.  Primarily, I assert that the adage, "You can't test in quality," holds.  Robustness and maintainability are both manifestations of a concise and consistent design carried through to the implementation.  If you don't believe that, you may as well not read this blog.

Fixing bugs one at a time is a way to keep your customers happy one bug at a time.  But restructuring software is a way to keep the customers who are complaining and the ones who are not complaining happy in the long term.  The customers who are not complaining are the deadly ones: They have already written off your enterprise and are quietly advising their friends to stay away.

In addition to capturing the silent detractors, not having to fix bugs one-by-one frees up resources for other endeavors, such as adding new features.  Depending on the expected longevity of the software and the nature of the competition, taking the long view and investing the resources required to produce correct, robust and maintainable code at any point in the software lifecycle can pay huge dividends.  Note: Software always hangs around longer than you expect.

Correctness

Correctness is whether the actual output of the program agrees with its expected output.  However, testing all possible inputs and outputs is usually infeasible for any nontrivial program.  To be verified reasonably, correctness has to be abstracted in terms of the model(s) the program is expected to implement.  This can only be done by specifying the expected behavior of the program in terms of models and then verifying -- by inspection of the implementation itself -- that the models are faithfully rendered in the code.  Testing may indicate that certain important cases are handled as desired, but without examining the underlying model, there can be no assurance that a nearby test case will yield the correct result.

Whether P=NP may be the fundamental question in Computer Science, but the fundamental problem in Computer Science is how to express what you want the machine to do.  Theorem-proving systems may be used to verify the correctness of a given piece of code.  But then the problem is merely mapped into a different domain: Once you have fed the essential information into the theorem-proving system, you may as well write a translator to go from theorem-proving language into executable code.  That is, the formal description of how the program is expected to behave contains all of the information required to generate that program.  The problem is "fundamental" because it cannot be transformed out of existence.

Given that at least part of the expression of a program is fundamental, there is no technique that can be proposed which will ensure that a given program is correct.  The best that can be achieved is to arrange the code so it is hard to write code that is incorrect.  Although there are many transformations that support this goal, they can be summarized as "arrange the code so as to expose the essential design decisions".

Techniques discussed here that are useful in exposing design points will be flagged as such, with a description of where they are especially applicable.  We cannot guarantee correctness, but we can make it very hard for bugs to remain hidden in the code.

Robustness

Robustness is the ability of a program to respond reasonably to incorrect or unusual inputs. Error handling is typically left out of software designs and implementations, or added as an afterthought.  Targeting robustness elevates the role of correct error handling to the same importance as the core functionality.

Erroneous inputs can arise from faults elsewhere in the system, or due to maintenance (e.g. adding a new feature).  When dealing with these odd inputs, robust software will do something reasonable, and "just work".  Code that is not robust will fail without giving any useful indication of where the problem lies.

Robustness is also a practical measure of modularity and encapsulation within the code, since being able to reason about the behavior of the software toward invalid inputs require a complete comprehension of the code that will encounter those cases.  Logic that is distributed across several modules (lack of modularity) and assumptions that can be arbitrarily rendered invalid (lack of encapsulation) will defeat attempts to gain a comprehensive view.  In this sense robustness is the flip-side of correctness: Correctness deals with the response of the program to anticipated inputs; robustness deals with the response of the program to unexpected inputs; both are only accomplished effectively when the coder can understand the implementation completely.

Maintainability

Maintainability is a measure of how easy it is to alter the code -- in response to bug reports or requests for new features.  In well-factored code, all of the logic required to deal with the new or erroneous case is in one place.  In that case, it is easy for a programmer to identify and implement the necessary changes.  This can be done with high confidence and entail a minimum of testing.

On the other hand, poorly factored code is hard to maintain because a successful change will require the programmer to locate all of the areas of the code affected, and make the modifications repeatedly until the desired behavior is achieved.  In that case, a debugger is almost always involved.  Ultimately, the coder has confidence only in the code he has inserted (if that).  Thus, a declaration of success entails extensive testing, as there is little confidence that the changes have not adversely affected other areas of the code.

The result of a modification to unstructured code is usually code that is even less structured.  At some point, the code will go over the precipice, requiring ever-increasing amounts of effort to implement succeeding changes.  The logical approach to battling this decline is to invest the time to add structure to the program as you are maintaining it.  That is the point of this blog.  Blaming the original coder for the state of the code is only a recognition of the problem -- not a solution.  Making the software better requires corrective action, and the techniques described here give you the tools to do so.

Maintainability is also about communication.  Because correct solutions to coding problems depend on the coder's comprehension of the code, maintainability is a measure of how easy it is for anyone (not just the original programmer) to read and understand the code.  Whether it is your future self or someone else that is tasked with maintaining your code, you will be thanked for producing correct, complete, comprehensible, commented and well-structured code.

No comments:

Post a Comment