NSWI170 Computer Systems

Guidelines to write a better C/C++ code

previous curse | all curses

Unforgivable curse #5: Encapsulation

Complex systems where entities cooperate (that includes our lives) require a division of responsibilities and encapsulation of private assets. The cooperation is then achieved only via well-defined interfaces. For instance, if you take a taxi, you merely tell the driver where would you like to go (that is the interface). You would not turn the steering wheel not shift gears (that are private assets). A similar concept is employed in programming and we will refer to it (albeit imprecisely) as encapsulation.

Motivation

A clear chain of command and delegation of responsibilities ensures that each asset (e.g., data) is being managed and the entity responsible for the management (e.g., a class or a module) knows how to do it. If multiple entities attempt to manage the same asset, it duplicates the knowledge (multiple functions/method needs to be implemented the same way) and it might even lead to corruption (when the functions get slightly different semantics, possibly due to incremental changes). In extension, it might also bring additional problems like security issues or race conditions (in case of concurrent access).

Explanation

Let us get more concrete. In the Arduino code, encapsulation would be most often required at the structure/class level. A class typically holds some member variables which (together) represent some object of our application. It is strictly forbidden to access the internal member variables from outside the class directly. In fact, it is best if you keep all member variables private in the terms of C++ scope control. Only access (possibly modification) is allowed via a well-defined public set of methods which we call the interface (please note that the word interface has also technical meaning in most OOP languages, but we are using it only in a conceptual sense).

The interface (the declared methods) does not have to comprise only accessors (getters and setters) for member variables (actually, it rarely does), but it should reflect the responsibilities of the class. For instance, a class TextDisplay responsible for controlling a simple matrix LCD capable of displaying text characters should not provide access to individual variables controlling the device but rather an abstraction like print() that would write a string at the cursor position or clear() that would remove all characters from the display.

Designing the right interface is often more difficult than the functional decomposition we already discussed. Sometimes, there might be multiple ways how to design an interface that might be considered good. However, for whatever decision you make, there should be a reason that you can present.

Examples

The following example presents an application that controls an alarm clock. It has three buttons -- one activates the clock (sets the alarm time), one resets it, and the last one can be used to snooze the alarm (postpone it once it goes off). The bad code part uses scattered global variables and accesses them directly from the main() function. The good (encapsulated) code uses two classes -- one holds the data and behavior of the AlarmClock, and the second one handles Button instances.

💩 Bad code 👍 Good code
bool alarmActive = false;
time_t alarmTime, snoozedUntil;
bool setBtnState, resetBtnState, snoozeBtnState;

bool processButton(int pin, bool &state) {
  // update state and return true, if button was pressed
}

int main() {
  // ...
  while (true) {
    if (alarmActive) {
      if (alarmTime < now &&
        (snoozedUntil == not_set || snoozedUntil < now))
      {
        enable_sound_output();
      } else {
        disable_sound_output();
      }

      if (processButton(reset_btn_pin, resetBtnState)) {
        alarmActive = false;
      }
      if (processButton(snooze_btn_pin, snoozeBtnState) &&
        alarmTime < now &&
        (snoozedUntil == not_set || snoozedUntil < now))
      {
        snoozedUntil = get_current_time() + snooze_delay;
      }
    }

    if (processButton(set_btn_pin, setBtnState)) {
      alarmActive = true;
      alarmTime = get_input_time();
      snoozedUntil = not_set;
    }
  }
}
class AlarmClock {
private:
  bool active;
  time_t alarmTime;
  time_t snoozedUntil;

public:
  void setAlarm(time_t at) {
    active = true;
    alarmTime = at;
    snoozedUntil = not_set;
  }

  bool isRinging() {
    time_t now = get_current_time();
    return active && alarmTime < now &&
      (snoozedUntil == not_set || snoozedUntil < now);
  }

  void snooze() {
    if (isRinging()) {
      snoozedUntil = get_current_time() + snooze_delay;
    }
  }

  void reset() {
    active = false;
  }
}

class Button { /* ... */ }

Button setBtn, resetBtn, snoozeBtn;
AlarmClock alarm;

int main() {
  // ...
  while (true) {
    if (alarm.isRinging()) {
      enable_sound_output();
    } else {
      disable_sound_output();
    }

    if (setBtn.isPressed()) {
      alarm.setAlarm(get_input_time());
    }
    if (resetBtn.isPressed()) alarm.reset();
    if (snoozeBtn.isPressed()) alarm.snooze();
   }
}

At the first glance, it would seem that the presented good code offers little benefits and is slightly longer. However, the benefits will become apparent once we need to modify the behavior. For instance, we might want to improve the clock by adding adaptive snoozing, which will adjust the snooze delay based on how many times the clock was already snoozed (e.g., making it shorter after each snooze so the user would not oversleep too much). In the bad code, we would need to add at least one new global variable and change the main() function (i.e., make modifications all over the code). In the good code, all the modifications will be contained solely within the AlarmClock class. Furthermore, we can even create multiple implementations of this class and change the behavior just by selecting different types for the alarm global variable (e.g., having almost identical code for the basic clock model and alarm clock deluxe model).

Note: If you have spotted that the bad code and the good code do not have the exact same semantics, good job! The reset button is handled slightly differently (in the bad code, it is handled only if the clock is active). This will not change the behavior of the clock unless we decide to add other functions (such as adaptive snoozing) which will require adjusting the behavior of the reset button.

C vs C++ approach

The presented example used more of a C++ approach. A similar thing can be achieved by C structures (struct) and functions (that will get that struct by reference/pointer as an argument). The C++ classes might get you better prepared for the upcoming courses, but either approach is fine from the perspective of encapsulation.