Now that we have seen some examples of good and bad coupling and cohesion, let's get to the strategies and approaches that a software architect can adopt to improve the modifiability of the software system.
A module should mark a set of functions, classes, or methods as the interface it provides to external code. This can be thought of as the API of this module. Any external code that uses this API would become a client to the module.
Methods or functions that the module considers internal to its function, and which do not make up its API, should either be explicitly made private to the module or should be documented as such.
In Python, which doesn't provide variable access scope for functions or class methods, this can be done by conventions such as prefixing the function name with a single or double underscore, thereby signaling to potential clients that these functions are internal and shouldn't be referred to from outside.
As seen in the examples earlier, coupling between two software modules is manageable if the coupling direction is one-way. However, bidirectional coupling creates very strong linkages between modules, which can complicate the usage of the modules and increase their maintenance costs.
In Python, which uses reference-based garbage collection, this may also create cryptic referential loops for variables and objects, thereby making garbage collection difficult.
Bidirectional dependencies can be broken by refactoring the code in such a way that a module always uses the other one and not vice versa. In other words, encapsulate all related functions in the same module.
Here are our modules A and B of the earlier example, rewritten to break their bidirectional dependency:
""" Module A (a.py) – Provides string processing functions """ def ntimes(string, char): """ Return number of times character 'char' occurs in string """ return string.count(char) def common(string1, string2): """ Return common words across strings1 1 & 2 """ s1 = set(string1.lower().split()) s2 = set(string2.lower().split()) return s1.intersection(s2) def common_words(text1, text2): """ Return common words across text1 and text2""" # A text is a collection of strings split using newlines strings1 = text1.split(" ") strings2 = text2.split(" ") common_w = [] for string1 in strings1: for string2 in strings2: common_w += common(string1, string2) return list(set(common_w))
Next is the listing of module B:
""" Module B (b.py) – Provides text processing functions to user """ import a def common_words(filename1, filename2): """ Return common words across two input files """ lines1 = open(filename1).read() lines2 = open(filename2).read() return a.common_words(lines1, lines2)
We achieved this by simply moving the common
function, which picks common words from two strings from module B to A. This is an example of refactoring to improve modifiability.
Usage of helper modules that abstract common functions and methods can reduce coupling between two modules and increase their cohesion. For example, in the first example, module A acts as a helper module for module B.
Helper modules can be thought of as intermediaries or mediators, which abstract common services for other modules so that the dependent code is all available in one place without duplication. They can also help modules to increase their cohesion by moving out unwanted or unrelated functions.
When we find similar code or functionality occurring in classes, it might be a good time to refactor them so as to create class hierarchies so that common code is shared by virtue of inheritance.
Let's take a look at the following example:
""" Module textrank - Rank text files in order of degree of a specific word frequency. """ import operator class TextRank(object): """ Accept text files as inputs and rank them in terms of how much a word occurs in them """ def __init__(self, word, *filenames): self.word = word.strip().lower() self.filenames = filenames def rank(self): """ Rank the files. A tuple is returned with (filename, #occur) in decreasing order of occurences """ occurs = [] for fpath in self.filenames: data = open(fpath).read() words = map(lambda x: x.lower().strip(), data.split()) # Filter empty words count = words.count(self.word) occurs.append((fpath, count)) # Return in sorted order return sorted(occurs, key=operator.itemgetter(1), reverse=True)
Here is another module, urlrank
, which performs the same function on URLs:
""" Module urlrank - Rank URLs in order of degree of a specific word frequency """ import operator import operator import requests class UrlRank(object): """ Accept URLs as inputs and rank them in terms of how much a word occurs in them """ def __init__(self, word, *urls): self.word = word.strip().lower() self.urls = urls def rank(self): """ Rank the URLs. A tuple is returned with (url, #occur) in decreasing order of occurences """ occurs = [] for url in self.urls: data = requests.get(url).content words = map(lambda x: x.lower().strip(), data.split()) # Filter empty words count = words.count(self.word) occurs.append((url, count)) # Return in sorted order return sorted(occurs, key=operator.itemgetter(1), reverse=True)
Both these modules perform similar functions of ranking a set of input data in terms of how much a given keyword appears in them. Over time, these classes could develop a lot of similar functionality, and the organization could end up with a lot of duplicate code, reducing modifiability.
We can use inheritance to help us here to abstract away the common logic in a parent class. Here is the parent class named RankBase
, which accomplishes this by abstracting all common code as part of its API:
""" Module rankbase - Logic for ranking text using degree of word frequency """ import operator class RankBase(object): """ Accept text data as inputs and rank them in terms of how much a word occurs in them """ def __init__(self, word): self.word = word.strip().lower() def rank(self, *texts): """ Rank input data. A tuple is returned with (idx, #occur) in decreasing order of occurences """ occurs = {} for idx,text in enumerate(texts): words = map(lambda x: x.lower().strip(), text.split()) count = words.count(self.word) occurs[idx] = count # Return dictionary return occurs def sort(self, occurs): """ Return the ranking data in sorted order """ return sorted(occurs, key=operator.itemgetter(1), reverse=True)
We now have the textrank
and urlrank
modules rewritten to take advantage of the logic in the parent class:
""" Module textrank - Rank text files in order of degree of a specific word frequency. """ import operator from rankbase import RankBase class TextRank(object): """ Accept text files as inputs and rank them in terms of how much a word occurs in them """ def __init__(self, word, *filenames): self.word = word.strip().lower() self.filenames = filenames def rank(self): """ Rank the files. A tuple is returned with (filename, #occur) in decreasing order of occurences """ texts = map(lambda x: open(x).read(), self.filenames) occurs = super(TextRank, self).rank(*texts) # Convert to filename list occurs = [(self.filenames[x],y) for x,y in occurs.items()] return self.sort(occurs)
Here is the modified listing for the urlrank
module:
""" Module urlrank - Rank URLs in order of degree of a specific word frequency """ import requests from rankbase import RankBase class UrlRank(RankBase): """ Accept URLs as inputs and rank them in terms of how much a word occurs in them """ def __init__(self, word, *urls): self.word = word.strip().lower() self.urls = urls def rank(self): """ Rank the URLs. A tuple is returned with (url, #occur) in decreasing order of occurences""" texts = map(lambda x: requests.get(x).content, self.urls) # Rank using a call to parent class's 'rank' method occurs = super(UrlRank, self).rank(*texts) # Convert to URLs list occurs = [(self.urls[x],y) for x,y in occurs.items()] return self.sort(occurs)
Not only has refactoring reduced the size of the code in each module, but it has also resulted in improved modifiability of the classes by abstracting the common code to a parent class which can be developed independently.
Late binding refers to the practice of postponing the binding of values to parameters as late as possible in the order of execution of a code. Late binding allows the programmer to defer the factors that influence code execution, and hence the results of execution and performance of the code, to a later time by making use of multiple techniques.
Some late-binding techniques that can be used are as follows:
This approach can be combined with object-oriented patterns such as factories, which create the required object at runtime given the name or ID, hence keeping the clients that are dependent on these objects immune from any internal changes, increasing their modifiability.
These approaches, when combined with deployment/configuration time or dynamic binding (using lookup services), can greatly increase the flexibility of a system and aid its modifiability.
We will look at examples of Python patterns in a later chapter in this book.
3.145.35.194