Software Design/Apparency of dependencies

From Wikiversity
Jump to navigation Jump to search

Apparency of dependencies is a code, interface, and external software quality. The dependencies, semantic connections, or mutual influences between entities in the codebase (variables, functions, classes, etc.) may be expressed explicitly in code or may only be deduced by the code reader when they fully understand the semantics of the code, or read the documentation for the interface or the software in full. This quality translates into the interface domain as the apparency of dependencies between the interface's operations, and into the domain of external software qualities as the apparency of dependencies between the software's functions, features, and configuration parameters.

The dependencies may be expressed explicitly in the code in three ways:

  1. The code for the dependent entity literally references the dependency: a function calls another function, a variable is assigned to another variable (or computed using another variable, or calls a function), functions in a class or a module access functions or variables from another class or module. The reverse dependency (which may also be important) is still not apparent (unless expressed in comments), but is easily discoverable via "Find usages"/"Reverse links" actions in IDEs or documentation viewers.
  2. Comments to code entities mentioning the dependencies and explaining their nature.
  3. The dependent entities being arranged very close (ideally, directly one after another) in code. This is the weakest form of explicit expression of code dependencies. The reader's focus when they browse code may be so narrow that even placing entities adjacently might be not enough to express the dependency (especially if the declaration of the first entity spans many lines of code). Also, the reader still needs to reconstruct the nature of the dependency from the semantics of the code.

The first way doesn't apply to the apparency of dependencies between interface or software functions. The third way may apply if the documentation entries for the dependent functions (operations, configuration parameters, etc.) are placed adjacently in the documentation.

Examples[edit | edit source]

State and lock[edit | edit source]

class MyClass {
  val state: MutableState
  val lock: ReentrantReadWriteLock
  
  fun doSomething() {
    lock.write {
      state.mutate()
      doSomethingElse()
    }
  }
  
  private fun doSomethingElse() {
    state.mutate2()
  }
}

In this example class, there is an implicit dependency between function doSomethingElse() and the lock which syncronizes access to the object's mutable state. This dependency could only be deduced after reading and understanding the semantics of the code. Moreover, if mutate2() function was less explicit about the fact that it mutates the state, it would be very easy to overlook this dependency and inadvertently call doSomethingElse() from some place in the class under a shared lock (lock.read { ... }) which in turn would permit a data race.

This dependency could be made more apparent in the following ways:

  1. Expressing the dependency directly in the code:
    private fun doSomethingElse() {
      lock.write {
        state.mutate2()
      }
    }
    
    A developer should still read through the code, understand the dependency (although it's much easier to do than when the dependency was implicit), and keep it in their mind, because an inadvertent call to doSomethingElse() under a shared lock would still be a bug, potentially leading to deadlock instead of a data race.
  2. Expressing the dependency via an annotation:
    @GuardedBy("lock")
    private fun doSomethingElse() {
      state.mutate2()
    }
    
    This approach falls between the literal code reference and identifying the dependency in the comment (documentation) because an annotation doesn't affect the semantics and execution of the program and intended to only be read by humans and static analysis tools. In this particular case, static analysis tools are of little help because @GuardedBy annotation doesn't support specifying the desired level of locking for a ReadWriteLock.[1] But, in theory, some static analysis tools could have supported this, making it almost unnecessary for developers to keep the discussed dependency between doSomethingElse() function and lock in their minds because they could rely on the static analysis tools to catch the improper use of the function.
  3. Expressing the dependency in comments:
    /**
     * Protects operations on the [state]: this lock must be acquired on the exclusive
     * level for operations which change the state (such as [doSomethingElse]), and on
     * the shared level for operations which only read the state.
     */
    val lock: ReentrantReadWriteLock
    
    ...
    
    /**
     * This method mutates [state] and therefore must only be called when [lock]
     * is acquired on the exclusive level.
     */
    private fun doSomethingElse() {
      state.mutate2()
    }
    

    Here, adding the comment to lock is not strictly required to ensure discoverability of the reverse dependency (from lock to doSomethingElse() function) because lock is referenced in the documentation comment for doSomethingElse() and this link should be discoverabe via "Find usages" action executed on the lock field in an IDE. However, adding a comment is the only way to make the reverse dependency always apparent to the reader.

  4. Getting rid of the dependency by replacing multiple entities with a single indivisible entity:
    class MyClass {
      val lockedState: RWLock<MutableState>
      
      fun doSomething() {
        lockedState.write { state ->
          state.mutate()
          doSomethingElse(state)
        }
      }
      
      private fun doSomethingElse(state: MutableState) {
        state.mutate2()
      }
    }
    

    Kotlin doesn't include a wrapper like RWLock in its standard library, but it could be implemented easily.[2] The abstraction is borrowed from Rust.[3]

    This is an example of perfect (atomic) cohesion.

Serialization and deserialization strategy[edit | edit source]

data class User(val name: String, val age: Int)

class UserSerializer : Serializer<User> {
  override fun serialize(output: OutputStream, user: User) {
    output.writeString(user.name)
    output.writeInt(user.age)
  }
}

class UserDeserializer : Deserializer<User> {
  override fun deserialize(input: InputStream): User {
    val name = input.readString()
    val age = input.readInt()
    User(name, age)
  }
}

In this code, there is an implicit dependency between the order of statements in serialize() and deserialize() functions: they must correspond. This is an example of external coupling.

The weakest form of making this dependency more apparent is putting these functions adjacently within a single class:

class UserSerializer : Serializer<User>, Deserializer<User> {
  override fun serialize(output: OutputStream, user: User) {
    output.writeString(user.name)
    output.writeInt(user.age)
  }
  
  override fun deserialize(input: InputStream): User {
    val name = input.readString()
    val age = input.readInt()
    User(name, age)
  }
}

The API could have made this mandatory by declaring a single Serializer abstract strategy class with two functions instead of two separate abstract classes.

Comments could also be added to the functions, making the dependency more apparent.

The most radical approach, as well as in the state and lock example, is to eliminate the dependency altogether by letting automatic tools to generate serialization and deserialization code from the data declaration:

@Serializable
data class User(val name: String, val age: Int)

If the data should be passed between services written in different languages, the data could be declared in a platform-agnostic way such as Protocol Buffers or Thrift.

Relations to other qualities[edit | edit source]

Obscure dependencies are a big contributing factor to codebase's maintainability.

Making dependencies more apparent by adding comments increases the code size and may introduce some repetitiveness, thus making the code harder to change. It also creates a risk for a developer to forget to update the comments along with the code (and thus inserting inconsistency), that is, makes the codebase more prone to developer's mistake.

As shown in the examples above, high cohesion correlates with a smaller number of dependencies in the first place, thus reducing the relative impact of their apparency or obscurity.

More apparent dependencies may clarify the semantics of some code, too, because dependencies are one of the ways to perceive the semantics.

Relevant practices[edit | edit source]

See also[edit | edit source]

References[edit | edit source]

  1. Goetz, Brian; Joshua Bloch; Joseph Bowbeer; Doug Lea; David Holmes; Tim Peierls (2006). Java Concurrency in Practice. ISBN 0-321-34960-1. http://jcip.net.  Appendix A. "Annotations for Concurrency"
  2. Petrenko, Eugene (March 1, 2017). "Guard access by lock in Kotlin".
  3. "std::sync::RWLock - Rust standard library".