C# Corner

Express Your Designs Clearly

Your code is the expression of your design intent -- make sure you communicate clearly.

Technology Toolbox: C#

When you write code, you're expressing your design intent. You might be expressing it clearly, or you might be hiding your design behind poor coding practices. The more clearly your code expresses your design, the easier it will be for developers who use and maintain your code. Providing unambiguous code makes it easier for others to understand what you've created and how it works. In this article, I'll discuss some of the most common ways developers hide their design intent from other developers, explaining how you can avoid such mistakes in the process.

One of the key steps you can take is to limit your accessor code. There are no restrictions on the amount of work you can do in a property accessor. However, your users expect that not much work occurs in a property setter, so you should limit your setter code to validating the argument and setting some backing field. If your property setter does more work, such as saving to a database or file or sending a save request to some Web service, make it a method instead.

A get accessor is just as stringent, if not more so. Users of your class assume that a get accessor executes quickly and can be called repeatedly without any ill effects. If your get accessor does quite a bit of work, such as querying a database, reading a file, calling a remote Web service, or performing some long-running calculation, change it to a method. Users expect properties to return quickly.

For example, suppose you have a class that handles a sequence of numeric values. One of the properties of a sequence is its sum. You might choose to write that this way:

// Poor implementation
public int Sum
{
   get
   {
      int sum = 0;
      numberSequence.ForEach(
         delegate(int num)
         {
            sum += num;
         });
      return sum;
   }
}

This code is correct as far as it goes, but it has a serious design flaw: It's doing too much work for a property get accessor. A developer writing code for this might decide (correctly) to use the Sum property as the data source for a databinding operation. Every time the form needs to retrieve the sum, it calls this accessor again. Each time, it does all the work to compute the sum. This causes a problem because the property looks like a data member from the outside, so users expect a quick response. They don't expect much work to be taking place. In this sample, it's not catastrophic, but consider the case of a get accessor that needs to make a remote call to a database to retrieve information and then do a computation on that information. That would cause quite a bit of work, and an unexpected time delay to your application.

Avoid Unnecessary Work
You can solve this problem a few different ways. The simplest is to replace the property with a method. Users expect that a method executes code and may take time to complete. However, this won't work if your end goal is to use this type as a databinding source. In this instance, you could use a lazy evaluation technique to cache the value the first time you calculate it:

private int? sum;
public int Sum

{
   get
   {
      if (sum.HasValue == false)
      {
         sum = 0;
         numberSequence.ForEach(
            delegate(int num)
         {
            sum += num;
         });
      }
      return sum.Value;
   }
}

This addition means you need to keep track of methods that invalidate the previous value. You must modify these two methods that add new items to the collection:

public void AddToSequence(int num)
{
   numberSequence.Add(num);
}

public void AddToSequence(IEnumerable<int> 
   moreNumbers)
{
   numberSequence.AddRange(moreNumbers);
}

Your first pass might do nothing more than empty out the sum:

public void AddToSequence(int num)
{
   numberSequence.Add(num);
   sum = default(int?);
}

This works, but only if you know you're writing a single-threaded program. If a context switch occurs between adding the new number and setting the sum to null, your users will get incorrect results. You need two locking constructs around the Sum accessor and the Add methods to ensure correctness (see Listing 1).

Of course, that's not the only way you can fix this issue. If databinding isn't a requirement, you could simply change the original property to a method.

Communicate With Immutable Types
The previous example illustrates one of the problems with mutable types: You need to do work to ensure that the internal state of the object remains consistent. Any method that modifies the state of an object must ensure that the new state is valid. That's why it's often a smart idea to create immutable types. An immutable type is one whose state cannot be changed once it is created.

An immutable type conveys a set of design decisions to both readers and users. It claims that re-creating a new object with different values is less expensive than modifying the state of an existing object. It claims that this type will often be used to store shared data, and that data represents a snapshot of a calculation (or other work). It does not contain behavior or provide any actions. You should create immutable types for types that hold values but don't define behaviors.

System.String is a good example of an immutable type. It stores a sequence of characters. If you want to manipulate a sequence of characters, the .NET framework provides other mechanisms, such as the StringBuilder class and the StringWriter class. Once you create a string, you can pass it to any method knowing that its value will not be changed now or anytime in the future. This is especially important with reference types, where a called method can cache the reference to an object in its internal state and modify it later.

The NumericSequence is also a good candidate for an immutable type. You can feed it a sequence of numbers in its constructor, and it will calculate the pertinent values from the sequence. Those values are always valid for the cached sequence (see Listing 2).

Is Sealed Best
Look at Listings 1 and 2 again. You can see that the NumericSequence and the ImmutableNumericSequence classes are marked as sealed. The sealed keyword ensures that no one can create a new class derived from the NumericSequence or ImmutableNumericSequence classes. Too often, developers create a class without any thought of whether someone will want to derive a new type from that class. I added the sealed keyword to these classes in Listings 1 and 2, because I cannot come up with a scenario in which one would want to derive from these classes and add new functionality. Therefore, I want to disallow it explicitly.

On the other hand, you should design non-sealed classes with inheritance in mind. Mark any methods that a derived class might expect to override as virtual. Note that any overrides you create in your class are also virtual, and further derived classes could also change that behavior. It's an explicit choice to support derived classes and to allow other developers to create extensions to your work through inheritance. Don't leave that kind of important decision to chance.

You also need to choose wisely between abstract classes and interfaces. Abstract base classes can provide default implementations for important behavior (think: System.Windows.Forms.Control). Interfaces specify a contract that different classes can implement in completely different ways. Choosing between them says quite a bit about the design of your system. An interface defines a set of methods that define a common behavioral pattern that can be shared across completely different types. The ISerializable and IEquatable<T> interfaces are good examples. No shared behavior can exist between the serialization code for an Employee type and a FiscalReport type. The data is completely different, so the implementation is completely different. However, the contract, or the interface, is the same.

Contrast that with the amount of shared code you can reuse in the Control class. Many of the common methods you might need are implemented already, often as virtual methods so that you can override them and change their behavior if necessary. However, that's an option, not a mandate.

Your coding choices express your design. Think carefully about the assumptions your users (and maintenance developers) will make about your design based on the code you leave behind. Did you think about derived classes? Did you think about common contracts that your types should implement? What about usage patterns? Will multiple threads share access to this type? If so, how did you handle it?

If you make sure that your design thoughts are expressed in your code, your types will be easier to use, to maintain, and to extend in the future. If you don't think about the uses (and possible misuses) of your class, you can be sure your users will find them and will tell you what went wrong.

About the Author

Bill Wagner, author of Effective C#, has been a commercial software developer for the past 20 years. He is a Microsoft Regional Director and a Visual C# MVP. His interests include the C# language, the .NET Framework and software design. Reach Bill at [email protected].

comments powered by Disqus

Featured

  • AI for GitHub Collaboration? Maybe Not So Much

    No doubt GitHub Copilot has been a boon for developers, but AI might not be the best tool for collaboration, according to developers weighing in on a recent social media post from the GitHub team.

  • Visual Studio 2022 Getting VS Code 'Command Palette' Equivalent

    As any Visual Studio Code user knows, the editor's command palette is a powerful tool for getting things done quickly, without having to navigate through menus and dialogs. Now, we learn how an equivalent is coming for Microsoft's flagship Visual Studio IDE, invoked by the same familiar Ctrl+Shift+P keyboard shortcut.

  • .NET 9 Preview 3: 'I've Been Waiting 9 Years for This API!'

    Microsoft's third preview of .NET 9 sees a lot of minor tweaks and fixes with no earth-shaking new functionality, but little things can be important to individual developers.

  • Data Anomaly Detection Using a Neural Autoencoder with C#

    Dr. James McCaffrey of Microsoft Research tackles the process of examining a set of source data to find data items that are different in some way from the majority of the source items.

  • What's New for Python, Java in Visual Studio Code

    Microsoft announced March 2024 updates to its Python and Java extensions for Visual Studio Code, the open source-based, cross-platform code editor that has repeatedly been named the No. 1 tool in major development surveys.

Subscribe on YouTube