英 文 : Pengtuo. Tech/Design Patt…

The policy pattern is most commonly used to avoid lengthy if-else or switch branch judgments, to provide extension points for the framework, and so on.

1. Principle and implementation

In GoF’s Book Design Patterns, it is defined as follows: Define a family of algorithms, encapsulate each one, And make them interchangeable. Strategy lets the algorithm vary greatly from clients that use it. That is to define a family of algorithm classes, each algorithm separately encapsulated, so that they can be replaced with each other. The policy pattern is used to decouple the definition, creation, and use of policies.

In fact, a complete strategy pattern is made up of these three parts.

  • The definition of a policy class is relatively simple. It consists of a policy interface and a set of policy classes that implement the interface.
  • Policy creation is done by the factory class, encapsulating the details of policy creation.
  • The policy pattern contains a set of policies to choose from, and there are two ways to determine how code using the algorithm chooses which policy to use: statically at compile time and dynamically at run time. Among them, “dynamic determination at runtime” is the most typical application scenario of policy pattern.

As shown in the figure above, the left box contains the policy interface and the implementation of the specific interface class, while the right Context class is responsible for obtaining and executing the corresponding policy in response to the requirements of the client code ClientDemo. It can also be seen from the figure that the policy mode conforms to the open and closed principle of extension development and modification closing.

1.1. Definition of policies

The definition of a policy class is relatively simple. It consists of a policy interface and a set of policy classes that implement the interface. Because all policy classes implement the same interface, code that uses algorithms is based on interface rather than implementation programming and has the flexibility to replace different policies. The following is an example:

// Policy definition
type Strategy interface {
	doOperation()
}

A / / strategy
type ConcreteStrategyA struct{}

func newStrategyA(a) *ConcreteStrategyA {
	return &ConcreteStrategyA{}
}

func (csA ConcreteStrategyA) doOperation(a) {
	// do something...
}

B / / strategy
type ConcreteStrategyB struct{}

func newStrategyB(a) *ConcreteStrategyB {
	return &ConcreteStrategyB{}
}

func (csB ConcreteStrategyB) doOperation(a) {
	// do another something...
}
Copy the code

Thanks to the convenience of interface implementation in Go, all of our policies implement one interface, so that the code that uses policies is based on the interface rather than implementation programming and has the flexibility to replace different policies.

1.2. Creating and using policies

Once a set of policies is defined, they are typically created according to the corresponding type of policies when they are used. In order to encapsulate the creation behavior, the factory pattern is used to mask the creation details, typically by pulling the code logic created by type into the factory function.

// Policy creation
type Context struct {
	strategy Strategy
}

func (c Context) Execute(a) {
	c.strategy.doOperation()
}

// This is equivalent to creating a factory function
func NewContext(strategyType string) *Context {
	c := new(Context)
	switch strategyType {
	case "csA":
		c.strategy = newStrategyA()
	case "csB":
		c.strategy = newStrategyB()
	default:
		panic("unavailable strategy")}return c
}
Copy the code

At this point, the policy pattern is easy to use, just get the type of policy you want and hand it to the factory function to get the policy instance.

The fact that the type of policy is written out in advance in the code logic is a “non-dynamic time-determination” approach that does not take advantage of the policy pattern and is more in line with “interface-based rather than implementation-based programming principles.” The most typical use of the policy pattern is when we do not know which policy will be used in advance, but dynamically decide which policy to use during program execution based on uncertain factors such as configuration, user input, and calculation results. Such as:

// Policy usage
The runtime determines dynamically which policy to use based on the configuration in the configuration file
func application(a) {
	// ...

	strategyType := "" // The runtime reads the policy type from the configuration file or database
	context := NewContext(strategyType)
	context.strategy.doOperation()

	// ...
}
Copy the code

2. Optimize code

In the previous section, we described what the policy pattern looks like and implemented it simply. However, in the factory function for policy creation, we still find switch-case structure used for type-to-policy selection. As the policy selection increases, the long switch-case structure appears. Obviously that doesn’t fit the title of our article.

So how do you use policy patterns to optimize verbose if-else or switch-case structures? In essence, it is the use of “table lookup”, according to the type of table lookup instead of according to the type of branch judgment.

As an example, suppose you have a requirement to write a small program that sorts a file containing only integer numbers separated by commas. Obviously, this problem can be divided into four cases:

  • Files are very small, direct quicksort algorithm;
  • If the file size is larger than memory, external sort is used.
  • File larger, you can use CPU multi-core, multi-thread processing file sorting;
  • If a file is too large to be processed by a single host, the MapReduce framework is used to improve sorting efficiency by utilizing the processing capacity of multiple hosts.

Obviously, this situation is based on the different size of the file to choose different sorting strategy, we can use the policy mode to achieve, is still respectively from the definition of the policy, the creation and the use of three modules to deepen the understanding of the policy mode.

package strategy

// Sort policy definition
type SortAlg interface {
	sortFile(path string)}type QuickSort struct{}

func (qs QuickSort) sortFile(path string) {
	// ...
}

type ExternalSort struct{}

func (es ExternalSort) sortFile(path string) {
	// ...
}

type ConcurrentExternalSort struct{}

func (ces ConcurrentExternalSort) sortFile(path string) {
	// ...
}

type MapReduceSort struct{}

func (mps MapReduceSort) sortFile(path string) {
	// ...
	}
Copy the code

The creation of a policy is still one main interface and multiple policy classes that implement that interface, and then the code that uses the policy can be programmed based on that interface rather than implementation. The next step is to create a Context class containing policy references, which is used for policy creation, analogous to factory functions.

// Policy creation
type SortContext struct {
	filePath string
	sortAlg  SortAlg
}

func (sc SortContext) Execute(a) {
	sc.sortAlg.sortFile(sc.filePath)
}

func NewSortContext(filePath string) *SortContext {
	GB := 1000 * 1000 * 1000
	fileSize := getFileSize(filePath)

	sc := new(SortContext)
	sc.filePath = filePath
	if fileSize < 6*GB {
		sc.sortAlg = QuickSort{}
	} else if fileSize < 10*GB {
		sc.sortAlg = ExternalSort{}
	} else if fileSize < 100*GB {
		sc.sortAlg = ConcurrentExternalSort{}
	} else {
		sc.sortAlg = MapReduceSort{}
	}
	return sc
}

func getFileSize(filename string) int {
	var result int64
	filepath.Walk(filename, func(path string, f os.FileInfo, err error) error {
		result = f.Size()
		return nil
	})
	return int(result)
}
Copy the code

Then get the path and use the corresponding sorting strategy

// Policy usage
func apply(a) {
	// ...

	filePath := "" // Get the file path
	context := NewSortContext(filePath)
	context.sortAlg.sortFile(filePath)

	// ...
}
Copy the code

In the Context class, the NewSortContext() function uses four if-else constructs. We can optimize this code with lookup tables.

// Create policy B
func NewSortContext(filePath string) *SortContext {
	GB := 1000 * 1000 * 1000
	fileSize := getFileSize(filePath)
	sc := new(SortContext)
	sc.filePath = filePath

	algs := []AlgRange{}
	algs = append(algs, AlgRange{0.6 * GB, QuickSort{}})
	algs = append(algs, AlgRange{6 * GB, 10 * GB, ExternalSort{}})
	algs = append(algs, AlgRange{10 * GB, 100 * GB, ConcurrentExternalSort{}})
	algs = append(algs, AlgRange{100 * GB, INT_MAX, MapReduceSort{}})

	for _, algRange := range algs {
		if algRange.inRange(fileSize) {
			sc.sortAlg = algRange.getSortAlg()
		}
		break
	}
	return sc
}

type AlgRange struct {
	start int
	end   int
	alg   SortAlg
}

func (ar AlgRange) getSortAlg(a) SortAlg {
	return ar.alg
}

func (ar AlgRange) inRange(size int) bool {
	return size >= ar.start && size <= ar.end
}
Copy the code

The current code is relatively more conform to the open closed principle, we put strategies in a lookup table, using this method, in more complex engineering, we need to modify the growth policy less code logic, need to change the code position also more centralized, minimizing code changes, centralized, reduce the possibility of errors. In this example, we put them in the slice structure, and more can be put in the library table or configuration file, so that more policies can be added without changing the code logic at all.

3. Summary

In fact, the main role of the policy pattern is to decouple the definition, creation, and use of the policy and control the complexity of the code so that each part is not too complex and too much code. For complex code, the policy pattern also allows it to meet the open-closed principle, minimizing and centralizing code changes and reducing the risk of introducing bugs when new policies are added.

However, it is not necessary to use the strategic mode at all times. According to the KISS principle, the best design is to make it as simple as possible. When the project has no extension value, rapid realization is also one of the key goals.