| |
|
|
 |
|
 |
April 1, 2007
December 29, 2006
Previously, I’ve raved about the benefits of test-driven development (TDD). It may come as a surpise, then, that I don’t think TDD (as defined and practiced today) will remain the definitive process for developing quality, maintainable software — at least, not in the long term, and not for large chunks of the software development industry.
The utility of TDD stems from the following observations, which are particular to the state of technology today:
- The cost of introducing a defect is high, proportional to the complexity of the application.
- The cost of fixing a defect is proportional to the time since the defect’s inception.
- The cost of changing the application is proportional to the time since the application’s inception.
TDD aptly deals with these challenges in the following ways:
- Comprehensive automated tests make it very difficult to introduce defects into the application.
- Ideally, defects are detected within seconds or minutes of their inception, thus minimizing the costs of fixing them (in reality, TDD tests are often not comprehensive enough to detect all defects, but they do a pretty good job with most of them).
- The process of TDD itself results in test-documented code that is modular, free of major coupling, client-friendly, and reasonably easy to change, thus minimizing the cost of application maintenance.
However, as technology changes, the observations that drove us to TDD may very well cease to hold. Let’s examine each of them and see how future technologies may make them untrue.
Observation 1: The cost of introducing a defect is high, proportional to the complexity of the application.
A defect in a complex application is hard to identify, and once identified, removing it may lead to additional defects, because of other code that depends on conditions established by the defect. By complex application, I am, of course, referring to the complexity of the source code, which is itself a measure of the number of levels between what the user sees (behavior) and how that is actually implemented (logic). Now let’s imagine some language, model, or tool, which reduces the number of these levels. Ideally, there would be just one: what the user sees. In such a case, if the user doesn’t like something he sees (a defect), he changes it immediately. Since there are few or no levels between what the user sees and how it’s implemented, the cost is low; the application ceases to be ‘complex’ (in the above sense of the word), regardless of how many features it has.
Observation 2: The cost of fixing a defect is proportional to the time since the defect’s inception.
There are a number of factors behind this observation: borrowing an analogy from manufacturing, if the printer spool is defective, but you don’t detect it until it’s been integrated into the printer cartridge, you have a lot more waste. Similarly, if you build code on a defect, it will come to depend on the behavior of the defect in subtle ways (programmers often write workarounds for defects they don’t understand and don’t want to risk fixing). QA personnel will waste time discovering the defect long after its inception. Project managers will waste time assigning it to developers. Developers will experience the overhead of context switching as they are forced to work on the defect, and will struggle to remember code written weeks or months before.
Yet, imagine before, some tool, model, or language that reduces the number of levels between what the user sees and how’s it’s implemented. If the number of levels is reduced sufficiently, perhaps to one, then fixing a defect is unrelated to the time since the defect’s inception.
Observation 3: The cost of changing the application is proportional to the time since the application’s inception.
The reason for this observation is that with time, the ‘complexity’ of the application increases. But if the complexity remains constant, that is, if the number of levels between what the user sees and how it’s implemented remains low, and does not grow with increasing number of features, then the cost of a fixed amount of change to the application remains constant over time.
In theory, then, each of the observations that lead us to TDD may eventually cease to hold, at least for a wide class of applications. In fact, I would argue that is already happening in ways most may not even notice. For example:
- A website designer of 7 years ago could have used Javascript-based TDD to create a dynamic menu; now the same website designer clicks on a button in a graphical website editor to insert a dynamic menu; the ’smarts’ have been moved from the fallible developer to the infallible computer.
- A desktop application developer of 10 years ago might have created a test, to verify that clicking a button executes a piece of code; now he uses his GUI designer to specify the chunk of code to execute when the button is clicked.
- HyperCard users with no programming experience routinely construct simple applications that satisfy their needs.
- In a matter of minutes, it’s possible to visually layout and specify the operation of a fully featured, dynamic e-commerce website, which would have taken a team of real software developers months to develop some years ago.
Obviously, these are toy examples, and TDD is going to be with us for a long time. But, I predict, not always, at least for many applications.
As the ultimate argument for this prediction, I would suggest a tool capable of transforming TDD tests into code. Since the tests used by TDD are themselves not TDD’d, it stands to reason that no one has found any economic benefit in doing so. This implies that if such a tool existed, developers would merely write tests which would be translated by strong AI into an application. Such a process is not TDD, yet it covers the entire class of applications for which a suitable AI can be developed.
November 28, 2006
In my last post on object-orientation gone bad, I explained some of the reasons why stuffing too much functionality into an object can be a bad thing for software maintenance. In this post, I’ll explain under what conditions this occurs and show you some clear guidelines that can help keep your OOP on the straight and narrow.
Virtually all well-designed objects can be classified as one of two things: information-providers, or information-consumers. The information-providers supply data, while the information-consumers use that data to perform useful work. For this reason, we can refer to information-consumers as data objects, and information-consumers as service objects.
If you’re wondering whether a given object is a data object or a service object, then a rough rule of thumb is to tack the word ’service’ or ‘manager’ onto the end of the object’s name. If doing so doesn’t seem to affect the responsibilities of the object, then it’s probably a service object; otherwise, it’s usually a data object.
Taking the example from the preceding post, if we add ’service’ or ‘manager’ to ‘File’, we get ‘FileService’ or ‘FileManager’. Since a manager of files is certainly different than a file, and has an altogether different set of responsibilities, we are pretty safe in concluding that ‘File’ is a data object, and not a service object.
With this distinction made, I can now state the following general rule:
Object-orientation goes bad when a data object acquires the properties of a service object. More precisely, the problem occurs when a data object provides methods that are not strictly functions of the data it encapsulates.
Stated in the latter way, the source of the problem becomes more apparent: when a data object provides methods that are not strictly functions of the data it encapsulates, then it has external dependencies. In the case of the bloated File object introduced in my last post, these dependencies were the user-interface and the file subsystems.
What may not be clear is that generally, these dependencies are bidirectional. For example, when a user double-clicks on a folder in a file explorer program, the program must call upon the File objects contained in that folder to display themselves; this means the user-interface subsystem depends on the File object, and the File object of course depends on the user-interface subsystem (by virtue of its many display methods).
A dependency can be thought of as a chain or a rope: it ties the program down and makes it more difficult to change. If A depends on B, then if you change B, you generally have to change A, whereas if A and B are completely independent, we can change them however we like with no extra effort involved. This fact is one of the two main reasons why bidirectional dependencies are so nasty: when A depends on B, and B depends on A, then any change to A may affect B to such an extent that A requires further modification, ad infinitum (the other reason is that it’s a lot harder for human brains to understand bidirectional dependencies).
So by stuffing File with extra dependencies on the file and user-interface subsystems, we tied it down with more ropes — double the required number, in fact, since at the bare minimum, the UI and file subsystems have knowledge of the File object, but the File object has no knowledge of either. This design choice, in turn, makes changing the application more difficult and error-prone, and hence more costly and risky.
At this point, it should be clear why bad object-orientation happens: because data objects are conflated with service objects, producing weird hybrids that increase the total number of dependencies. It should also be reasonably clear how we can prevent that from happening: enforce a clear separation between information-providers and information-consumers, and assure that the methods of information-providers are functions strictly of the data they provide.
In the File example, we should have stopped when we had the following list of methods:
File getParent() const;
bool isRoot() const;
bool isChildOf(const File &file) const;
bool isParentOf(const File &file) const;
Partition getPartition() const;
int getLevel() const;
All these methods are functions strictly of the data itself, with no external dependencies (assuming a standard convention for representing such things as path separator and partition name or letter). Therefore, it does no harm (but rather, a great deal of good) to consolidate these methods into the File object. However, all the methods we added after that point are dependent on other subsystems, and therefore, should not be member functions of the File object. Rather, they should be functions of (say) UIComponentDisplayService and FileManager objects. This minimizes the total number of dependencies (and eliminates a bidirectional dependency), thus making code maintenance that much easier.
From these principles comes the Model-View-Controller (MVC) design pattern (which separates the model of the application’s domain from the processing logic and the view of the model), which many programmers are familiar with. Now you know why this design pattern is powerful.
November 18, 2006
When the wonders of object-orientation were heralded the world over, as they made their way into the mainstream languages of the time, new and cutting-edge software engineers rushed to apply the new invention everywhere they could — even in places where it didn’t belong. I call this phenomenon object-orientation gone bad, and its effects on code bases small and large can still be felt.
The idea of encapsulation is a powerful one: roughly stated, an object should alone be responsible for manipulating its own data. You only tell the object what you want it to do, and the object will do whatever bit-mashing is required to carry out your request. In this way, if you don’t have your hands under the hood, so to speak, you won’t know or care if the engine suddenly changes from diesel-powered to hydrogen-powered. Thus the cost of some kinds of changes is reduced, which makes it easier to maintain smaller programs and possible to construct larger programs.
To see how this powerful idea can go bad, let’s take a look at a hypothetical little data structure in a procedural language such as C. The data structure in question will model a file, which contains both path and name information.
struct File {
char *path;
char *name;
};
This horrific little data structure is a compelling argument for the use of object-orientation (as well as garbage collection and a good many other features of modern languages). As written, it permits anyone in the world to directly modify, re-assign, allocate, and free the path and name elements of any File structure. In a large program, this will lead to a number of nasty consequences, among them:
- Some client A will pass a File structure to some other client, who will modify it and corrupt the logic of client A (who assumed it would not change throughout its operation).
- A client will not expect path or name to be initialized, and will therefore re-allocate memory for both components, leading to memory leaking.
- A client will assume a particular File structure is valid, but it won’t be valid because it hasn’t been initialized or has been freed by someone who shouldn’t have freed it.
- Likely, the path and name fields will be initialized in different ways by different (largely duplicated) code, which can lead to subtle bugs if the duplicated code does not perform the initialization identically (for example, one code might append a path separator to the end of the path, but some other code might not).
These problems all stem from the fact that anyone can touch the data. There are no access controls. No allocation controls. No ownership information. Everyone can modify the data at any time, in whatever way they want.
To address these problems, let’s take a first stab at turning the File data structure into a File object, written in C++ (not my language of choice for object-orientation, but it suffices to get the point across).
class File {
private:
char *path;
char *name;
public:
File(const File &that) : path(0), name(0) {
setPath(that.getPath());
setName(that.getName());
}
File(char *p, char *n) : path(0), name(0) {
setPath(p);
setName(n);
}
~File() { delete [] path; delete[] name; }
void setPath(const char *p) {
delete [] path;
path = new char[strlen(p) + 1];
strcpy(path, p);
}
void setName(const char *n) {
delete [] name;
name = new char[strlen(n) + 1];
strcpy(name, n);
}
const char *getName() const {
return name;
}
const char *getPath() const {
return path;
}
const File &operator = (const File &that) {
setPath(that.getPath());
setName(that.getName());
return *this;
}
};
(Note I’ve purposely retained the same data format as the data structure; not using std::string, for example. Also, the implementation is made intentionally dumb because it’s simpler that way.)
With this simple change, we’ve done a world of good. Now there are no more memory allocation problems, because the path and name are always copied into internal buffers, which are managed by the object. We can also control access to an object, by using the C++ keyword ‘const’. So if a client is careful to pass only ‘const’ references to a File object, it can ‘know’ the passed object won’t be modified by the invoked code (C++ is fundamentally broken; technically, it’s always possible to modify anything in C++, but this is a limitation of the language that we really can’t do anything about).
However, we’ve only just begun to reap the benefits of object-orientation. Now that we’ve imposed some control on the File object, we can start to think about reducing code duplication. For example, getting the parent File object of an existing File object is a very common operation, and is likely to be repeated in multiple places in the code base. A natural way to eliminate this duplication is to push the functionality into the File object — adding, for example, a getParent() function that retrieves the parent File object.
Systematically, we can comb the code base, looking for common operations that clients perform with File objects, and pushing the functionality into the File object. Midway through this process, we might end up with a list of member functions similar to the following:
File getParent() const;
bool isRoot() const;
bool isChildOf(const File &file) const;
bool isParentOf(const File &file) const;
Partition getPartition() const;
int getLevel() const;
At this point, we should stand back for a moment to admire our creation. Thanks to object-orientation, we’ve completely eliminated access, ownership, and allocation issues, and greatly reduced code duplication because we’ve centralized common operations. In essence, we’ve dumbed down the interface to a file abstraction — we’ve made it much easier for programmers to use the abstraction without making mistakes that would require gigantic (in some cases, even superhuman) cerebral resources to avoid.
Seeing these benefits, it’s natural to want to milk this approach for all we can. So the next thing we might do is look at other common File operations, and try to determine if they fit naturally in the File object. For example, lots of code needs to determine if a File object is a file or a directory; still other code needs to list all the files in a given directory; and other code needs to create, delete, rename, and perform other operations on files.
All of these are common operations, they involve File objects, and it seems ‘intuitively’ correct that a File object should encapsulate the implementation details of these operations. So if we followed through on our intuition, then we might end up adding the following member functions:
bool isFile() const;
bool isDirectory() const;
File *listFiles() const;
bool makeDirectory() const;
bool createNewFile() const;
bool renameTo(const File &dest);
std::istream getInputStream() const;
If we implemented the File object as described above, we could hardly consider ourselves bad programmers. After all, many standard libraries have taken the same approach. Indeed, even Java’s File object has gone down the same path.
We needn’t end where other libraries have stopped. If we continue on to the logical conclusion, we might add member functions for displaying the File to the user (if, for example, our application allows users to manipulate files); perhaps something along the following lines:
void displayFileInList(const WindowList &list) const;
void displayFileContents(const Window &window, int firstRow, int firstColumn) const;
void displayFileAsIcon(const WindowIcon &icon) const;
...
If we actually implemented this approach, we’d end up with a monolithic File object, containing every operation you could possibly want to do with or to a File object, each installed as a member function in the class.
This, my friends, is object-orientation gone bad.
The main problems with this approach are its inherent inflexibility and its merging of concerns.
The only code that doesn’t change is code that isn’t used anymore. Living code evolves continuously, due to changing requirements (or, in some cases, changing knowledge of requirements). Today the code is written to interact with a local file system, and to display files in a window; tomorrow the code might need to read from an FTP site, or display files to an HTML table. Just reading files from an FTP site would involve either adding more methods to deal with an FTP site (getFTPInputStream(), for example), and then changing the whole code base to take advantage of them; or to modify all methods of the File class that interact with the file system, both of which are tedious, awkward, error-prone, and increasingly unmaintainable as the code base expands in scope. And while it might seem simple enough to display a single File object to an HTML table, consider that if the program has 100 objects which all display themselves, and a programmer wants to add a new display option, he has to search out and find all 100 objects, modify all of them, and then modify the code that interacts with them. Ca-ta-stro-phic.
Moreover, by trying to do everything, the File object contains both user-interface and file system code. This makes it much harder to understand the class, which in turn increases the probability that someone will modify the class in a detrimental way. Further, since much of this code is going to be shared with other objects that interact with the user-interface or file system, this implies either code duplication or delegation to some core components, both of which increase complexity and make the code harder to maintain.
Hopefully by now, I’ve succeeded in convincing you that ‘object-orientation’ can be pushed too far, with deleterious effects on program maintainability. In my next post, I’ll formulate some very simple, clear rules that can help you draw exact boundaries between what object-related functionality should be the responsibility of the object, and what object-related functionality is better implemented elsewhere. I’ll also show how these rules lead to the famous Model-View Controller (MVC) design pattern.
September 28, 2006
Most software products have indefinite lifespans. The first version of Microsoft Word was released in 1989, and it’s still going strong.
Applications with indefinite lifespans need to be maintained, which is a term used to describe both the addition of desired functionality (done typically to catch up with or pass the competition), and the removal of undesired functionality (defects or bugs). Without maintenance, customers abandon a product because eventually, it fails to meet their needs as well as the competition, or they simply tire of putting up with defects in the application.
Software maintenance is usually the costliest part of software development, because it’s an ongoing expense and because the cost generally increases with time. This implies that to minimize software development costs, one strategy is to write software that is easy to maintain. Indeed, easily maintainable software is one of the most sought after goals in the industry.
As I see it, there are two properties of maintainable software:
- It’s clear where and how to make a change.
- The cost of making a change is low.
It is my contention that ’self-testing’ code satisfies both these properties, to the degree that it is self-testing (my contention is not, however, that self-testing code is the only way to achieve maintainable software).
Code that has been developed using Test-Driven Development, or which has been retrofitted with extensive automated developer tests, possesses two properties by definition: it is modular (since you can’t test something that isn’t), and it is documented (the automated tests serve as the definitive documentation for how the code is used, guaranteed not to be obsolete). The modular architecture makes it easier for developers to know where to change the code, while the documentation (in the form of automated tests) makes it easier for developers to know how.
Self-testing code also dramatically reduces the cost of making a change. The cost of making a change is largely a function of the impact of the change.
In code that isn’t self-testing, the impact of a change is unknown: the developer will make the change and then manually test the application, trying to learn what broke so she can fix it. This is a high risk activity, because of the high amount of uncertainty and the possibly indefinite duration.
In code that is self-testing, the impact of a change is known immediately, simply by running automated tests. A developer can therefore either choose the change that minimizes the impact, or directly address the impacted areas, which are identified by the automated tests.
Therefore, self-testing code is inherently easier to change, because the cost of change is low and bounded by the extent of test coverage.
If you’re going to do only one thing to produce maintainable software, do automated developer tests — preferrably via test-driven development, but even unit testing is sufficient. It’s a braindead simple way of getting highly maintainable software.
September 27, 2006
You Aren’t Gonna Need It — otherwise known as YAGNI, and pronounced exactly the way you think it is (yes, really).
This is a defining cry of agile development: simultaneously an exhortation to Do the Stupidest Possible Thing, and a reminder that too much time is spent writing code and developing architectures that are later abandoned or were never needed.
The principle derives from a philosophy of economy. Writing and maintaining code is costly. If you want to minimize those costs, then you need to minimize the amount of code. The minimum amount of code is equal to the amount of code you need right now. So if you believe in YAGNI, you shouldn’t plan for or try to anticipate the needs of tomorrow. Don’t write a function or a method or a class or anything else unless doing so fulfills a requirement of today.
Even if it would be really ‘cool’ or ‘elegant’ for an object or API to have some feature or another, if you don’t need to use that feature today, don’t implement it (because You Aren’t Gonna Need It). Even though it would be nice if a particular algorithm were as fast as theoretically possible, if you don’t need that level of performance today, then use a simpler, slower algorithm. And so on.
If followed strictly as stated, however, YAGNI leads to some degree of reengineering. If the customer ultimately wants the app to store data in a database (so it’s accessible to third party tools), but the feature you need today merely involves persisting product information, then YAGNI would imply you persist the information in the way that requires the least amount of effort. Only in this case, you’ll end up throwing away that code or having to persuade the customer that he doesn’t really need a database (and who knows, maybe he doesn’t).
I personally view YAGNI as a simplification of a larger goal: the goal of defering potentially costly decisions until the last moment possible. In other words, put off the decision of adding that feature until you really need it; delay optimizing that inner loop until you know you have to; and don’t tie yourself to a database or any other form of storage with a non-trivial implementation cost until you absolutely must.
Now there are two ways to defer costs: there’s the age-old way, where from time immemorial overworked hackers have slopped together what the bosses demanded of them, in the simplest and often ugliest way possible; and then there’s the agile way.
What’s the agile way? It stems from the observation that to maximize the utility of defering costs, you must write code that makes it easy to defer costs. With that in mind, let me restate YAGNI more precisely, as followed by agile developers, and in my own words:
- Defer coding something until you really need it.
- Make it easy to defer coding.
In particular, (2) can be seen to imply comprehensive, automated developer tests, a cornerstone of many agile development processes; it can also imply many other quality software practices, such as strong encapsulation, object-oriented design, minimization of coupling, and so forth.
A change to code that isn’t under automated tests is risky and potentially costly. But automated developer tests allow the impact of a change to be known immediately, simply by running the tests. Therefore, they lower the cost of change and make it easier to push decisions later into the development cycle.
(Other relations are left as an exercise for the reader to figure out.)
In the form expanded upon above, following YAGNI leads to the happy place of well-designed code that does what you need it to, but no more. Since the code is well-designed, if you need it to do more tomorrow, you won’t have a problem changing it.
August 27, 2006
Test-Driven Development (TDD) is a newly popular approach to developing software that tries to move a developer from start to completion in a series of very small, incremental steps, each driven by the specification of the external behavior of the code being developed.
In a nutshell, the process of TDD can be summarized as follows:
- Write a test for the smallest sliver of the behavior you need. Usually it’s a small aspect of a single method (many such tests will be required to fully specify the behavior of the method, but you start with a single test).
- Run the test to verify that it fails.
- Write just enough code to pass the test in the simplest possible way (if you need the code to do more, you need another test).
- Run the test to verify that it and all other tests affected by the change succeed. If they don’t succeed, throw away your changes and try Step 3 again (though personally, I prefer finding the cause of the problem and fixing it; it usually takes only a few moments and gives you better insight into the kinds of mistakes you’re more likely to create).
- Refactor the code to improve its design, making sure all existing tests pass.
- Repeat the process, starting with Step 1, until all the behavior you need is specified (and implemented).
For the typical high-level business logic application, this recipe works magic, and the resulting code is modular, stable, and free of serious defects.
If you’re the curious type, you’re probably wondering: What’s the secret ingredient?
There are a number of them. In order of appearance:
- Step 1 forces the developer to express the ’sliver of behavior’ in a way that’s testable. Testable code is modular (it doesn’t have complicated dependencies), and it tends to be easy to use. If the developer started in the opposite direction, she would design for ease of implementation — but by starting with a test, she designs for ease of use.
The importance of testing for a ’sliver’ of behavior (instead of a huge chunk) is threefold: (1) implementing a sliver of behavior is far less risky than trying to implement a huge chunk; (2) tests at a very fine level of granularity make it easy to localize defects introduced during code maintenance; and (3) when the code is complete, nearly every aspect of code, even the tiniest ones, will be covered by automated tests.
- Step 2 is a check against the accuracy of the tests (albeit weak). If a test is correctly written, then if it tests for behavior that doesn’t exist yet, it should fail. So if it doesn’t fail, the test is usually defective.
- Step 3 hides a great deal under its simple facade. The simplest possible way to pass a test is usually not the same way the test will be passed after development is complete (the final code will pass the same test in a different way). So by passing a test in the simplest possible way, the developer is writing code that (at best) will later need extensive modification, or (at worst) might even be thrown away altogether. So why do it?
The reason is subtle, but extremely important: by passing the test in the simplest possible way, the developer is forced to limit the functionality of the code to what is actually tested for. For example, if a developer writes a test that add(2, 2) == 4, then the simplest way to pass that test is to return 4 (not to return the sum of the operands). And that’s exactly what the developer should do: return 4. If she wants her code to do more, she needs to write more tests. Followed strictly, this practice leads to code whose behavior is strongly specified, rather than weakly implied. This in turn leads to far lower defects, because the developer makes fewer assumptions that aren’t tested.
- Step 5 is a way of counteracting an inherent weakness of TDD. Repeatedly passing tests in the simplest way possible can lead to some ugly code that is difficult to follow (well-tested, modular, easy-to-use code — but ugly code nonetheless). Fortunately, since the code is strongly tested, the developer can quickly and safely clean up the code using standard refactoring techniques.
Together, these secret ingredients make for a tasty treat — one enjoyed by developer and customer alike.
May 19, 2006
Few programming idioms are so ubiquitous they spawn the creation of language constructs. One such idiom, for lack of a better name, is the field accessor idiom: wherein the field of an object is accessed using getter and setter methods.
For example, if you run into an object Person with a field name, you’re likely to see getName and setName methods (or their language-level equivalents, which in some cases look like you’re accessing the data directly), which are used to retrieve and set the name of the person, respectively.
This idiom is a child of object-oriented programming. In OOP tradition, you’re not supposed to touch the data; you’re supposed to ask for it. By requiring a service (the provision of some piece of information) and not a data format, the implementation of that service is free to change. Code is decoupled, and programmers who maintain that code later will thank you.
I have a gripe with this idiom. I grant it’s an improvement on direct data access — of this there is no question — but my contention is that the idiom is dangerous, overused, and often (but not always) supplanted by a better idiom.
Specifically, my disdain for the idiom is caused by its reliance on setters. Getters, I have no issue with, providing they are used judiciously and principally on data classes (it’s usually better to tell than to get). So strong is my reaction to a setter method, that when I see one, I immediately wonder how the code is broken, not if it is broken. Whence came this animosity?
In my experience, setters are the root of all kinds of evil:
- Setters rely on programmers to safeguard access to the underlying data.
In the preceding example, I can’t just pass around a reference to a Personobject, since I have no way of knowing if the methods I pass it to are going to modify it (despite this, this is exactly what many developers do!). Sure, I can inspect the code, but code isn’t static — it’s constantly changing. By inspecting the code, I may be able to ensure client code does not currently modify an object, but I can’t know that it will not, for the lifetime of the application.
A common solution in Java is to wrap the data object in an immutable wrapper, and pass around the wrapper. Some languages (not Java) support compiler-checked contracts (such as const, in C++), but these are only valid when programmers use them — and even if a design starts out using them, with time, these constructs have a tendency to disappear, since programmers progressively remove them whenever they deem it necessary to modify the underlying object.
In all cases, the programmer is the weak link: when the programmer forgets to safeguard the data, not if, a latent defect is introduced.
-
Setters can often transition an object through invalid states.
Say the example
Person object has a city and a zip code. If each one has a setter method, then in order to change both the zip code and city, it’s necessary for the object to transition to an invalid state (wherein the zip code does not belong to the specified city). If you’re relying on language-level constructs for accessors, then you’re screwed. If you’re not, then you can solve the problem by exposing a single setCityAndZipCode method instead two separate setter methods.
However, my experience is that programmers create setter methods instinctively, or sometimes automatically with the aid of their IDE, so that such problems are seldom found.
Setters make it easy to create cyclic dependencies.
For example, a Person object may hold a reference to a MedicalRecord object, which in turn holds a reference to the Person object. Such code is difficult to write correctly, since both the medical record and the person have to be linked to each other, when either is created, and still more difficult to maintain. When A depends on B, and B depends on A, then even a slight change to A may force you to revise B, which in turn may force you to revise A.
One-way dependencies, when A depends on B, but B does not depend on A, are much easier to maintain, because when B changes, you have to revise only A.
-
Some methods of an object will not work correctly unless certain setter methods have been invoked.
Often times an object needs to have several fields initialized before other methods of the object can be used. For example, a
DiskDocument might require you to set the name of the document before you can invoke the save method, which saves the document to disk. Worse still is the case where setter methods have to be used in a particular order — for example, you have to set the Partition before you can set the name, because the setName method checks to see if the name is already taken.
Sometimes the vast majority of methods of an object will work without a particular setter being invoked. Depending on how extensive your test coverage is, you might introduce a defect, and not even know it, all because you forgot to set some field (or because you set it, but at the wrong time, or without setting other fields first, etc.).
Cases such as these have one thing in common: the use of setters hides dependencies that should be exposed, and therefore make use of the object both difficult and error prone. Now this is definitely a case where the problem is with the way setters are often used, and not with setters, per se, but it is still an argument against the use of setters — you need to base policy on what programmers actually do, and not what they should do.
-
Setters are not thread-safe.
Setters require synchronization in order to ensure the integrity of the underlying data, and synchronizion logic for non-trivial cases can be some of the trickiest logic to test, develop, and debug. Even with synchronization, code that uses objects with setter methods often suffers from indeterminism due to race conditions and timing issues.
So if setters are so bad, what can we do about it? The answer is obvious: don’t use them. An object with no setter methods of any kind is immutable, and implements the Immutable design pattern. In every way that setters are evil, immutable objects are superior:
- The data of immutable objects is always safe.
Because an immutable object cannot change, programmers don’t need to worry about passing around references to the object.
-
Immutable objects are (or can be made to be) always valid.
In order to obtain a reference to an immutable object, a programmer has to construct it. The code involved in constructing it can check to make sure every field is valid. In this way, programmers never have to worry if an object they have a reference to is valid: the fact that they have a reference to it means that it is valid.
-
Immutable objects make it difficult to create cyclic dependencies.
Returning to the previous example of a person and a medical record: if the person is immutable, it cannot be constructed without specifying all fields (including the medical record, if it contains a reference to one); if the medical record is immutable, then it too cannot be constructed without specifying all fields (including the person, if it contains a reference to one). This means it is literally impossible to create a medical record that references a person that references the medical record. This leads to much cleaner code with one-way dependencies.
-
All methods of an object will always work.
Assuming the creation logic contains appropriate verification code, you are assured that if you have a reference to an object, you can use any of its methods (subject to the constraints expressed in the documentation of the object). In addition to resulting in safer programming, this benefit also reduces code quantity: it’s not necessary to check that certain setter methods have been invoked before calling certain methods.
-
Immutable objects are thread-safe.
An immutable object may be used by any number of threads simultaneously, without issue.
Immutable objects have their own peculiar properties and limitations, which I intend to explore in a future post.
July 30, 2005
I recently posted yet another job on Elance, this one to finish a corporate website. To minimize bids and narrow the focus to the highest quality service providers, I decided to try the “Select” project level, which incurs a $25 fee (refundable if a bid is chosen; it basically proves to bidders you’re serious about the project) and imposes certain minimums on the bidding amount ($550, in this case). Only Select bidders—who both pay higher monthly fees and higher fees to bid—can bid on Select projects.
I still anticipated (dreaded?) receiving more than a dozen bids, and based on past experience, examining bidders’ portfolios is not always sufficient to distinguish between bidders. Moreover, one time I confirmed a bidder had listed projects in his portfolio which he did not complete. So while the portfolio can be a useful measure of a bidder’s quality, it cannot serve as a sole indicator.
This time, I decided to ask the bidders for some additional information:
- Show me a website you think exemplifies excellent design.
- Show me your best-designed website.
- Quote a paragraph from a book on user-interface or web site design.
The answer to the first question gives me a feel for what the bidder thinks is good design (after all, we might not agree). The answer to the second question lets me know how well the bidder can realize his or her vision of a good design. The third question, I asked under the theory that, maybe, someone who is really passionate about design might buy a book or two on the topic (as I have done with my passions, including software development, mathematics, and computer science).
I posted the project, sat back, and waited for the bids to roll in.
As expected, I received more than a dozen bids (two dozen, actually). Most of the bidders failed to answer any of the questions I asked, instead preferring to quote their standard blurb about how they are the best in the business….yada, yada. Of the remaining bids, most insisted on listing their own web sites for the first question and dumped a list of 10 or more web sites in response to the second question. Universally, the ones who engaged in said dumping either did not understand or simply did not reply to the third question, although one such bidder suggested, “We are so good we could write our own book on design!” (Ok. Thanks for letting me know. Bye now.)
Without hestiation, I discarded all of these bids. Thinking, if a bidder can’t take the time to actually read the project that he or she is bidding on, and then respond to the questions asked by the buyer, then I have no confidence in the bidder’s ability to implement my project exactly as specified.
This trimmed the list down to 4 contenders. I examined the portfolios, and ranked them in order of 1-3. The top two were tied for number 1 spot, and judging solely based on their portfolios, it would be a tough call. I asked both to comment on my website, and one of them responded with thoughtful remarks (reflecting good design skills), while the other one didn’t even get back to me. That sealed the deal—despite the fact that the winning bidder was from the USA, and therefore, was both charging the highest amount, and promising a lesser amount of work than many other bidders (who had offered to redesign the whole site—multiple times, if I should so desire).
I’m adding this to the bag of things I’ve learned from being a observant customer: listen to your customers. Yeah, you need to have the goods (or services), but if you don’t give your customers the attention they deserve, and fully address the questions they have, then they’re going to find someone else.
I know I would.
|
|
|
 |
| © 2005 degoes.net. All Rights Reserved. |
|
|