Project Evolution
2022 April 13 19:57 stuartscott 1024¤ 987¤
Every project starts small and grows over time - a mature codebase looks very different from the first code written. However this is something most tutorials claiming to be the "right way of organizing code", and templates having the "correct project layout" seem to be missing.
New developers can easily get analysis paralysis trying to figure out the proper directory structures, appropriate package names, and separation of concerns before a single line is written. Worse still; once they actually get started on the codebase the rigid organization hinders progress when the project inevitably expands in a direction they had not anticipated.
In this article I'll show you the steps I typically follow when starting a project by creating a Polish notation calculator. These are not golden rules, nor are they perfect, but they should help you get productive without too much fuss.
This code shown is available on Github and the commit history should align with the steps below.
-
2022 April 13 20:00 stuartscott 545¤ 1429¤
Step 1: Main
Just as 1st gear is designed to get your car moving from stationary and not to reach top speed, your first step in a project should be to get something running and not deploy in production.
Create
main.go
with a main function and run it;package main import "log" func main() { log.Println("Success!") }
> go run . Success!
By completing this simple first step you've confirmed your toolchain is working and you've started off the project with a small win - it compiles and runs, let the dopamine flow.
-
2022 April 13 20:01 stuartscott 503¤ 2357¤
Step 2: Subtasks
Write out, in comments not code, the tasks that must be completed for the common use case;
func main() { // Parse operation from args // Parse parameters from args // Calculate result // Print result log.Println("Success!") }
This step helps to reduce scope, focus your mind, and breaks the task into manageable pieces. You may also find that writing out these steps helps you get back into the project more quickly after a distraction or interruption.
-
2022 April 13 20:01 stuartscott 1171¤ 3542¤
Step 3: Win Streak
Implement each of the subtasks and get yourself a streak of small wins. The goal here is to get the common use case working without worrying about the length of the main function;
package main import ( "log" "os" "strconv" ) func main() { // Parse operation from args var operation func(int, int) int switch os.Args[1] { case "+": operation = func(a, b int) int { return a + b } case "-": operation = func(a, b int) int { return a - b } case "*": operation = func(a, b int) int { return a * b } case "/": operation = func(a, b int) int { return a / b } case "%": operation = func(a, b int) int { return a % b } } // Parse parameters from args a, err := strconv.Atoi(os.Args[2]) if err != nil { log.Fatal(err) } b, err := strconv.Atoi(os.Args[3]) if err != nil { log.Fatal(err) } // Calculate result result := operation(a, b) // Print result log.Println(result) }
> go run . + 1 1 2
-
2022 April 13 20:02 stuartscott 2859¤ 4227¤
Step 4: Refactor
Now that the project has reached the milestone of "common case works" you can safely refactor some code out of the main function and into auxillary functions;
package main import ( "fmt" "log" "os" "strconv" ) func main() { // Parse operation from args operation, err := ParseOperation(os.Args[1]) if err != nil { log.Fatal(err) } // Parse parameters from args a, err := strconv.Atoi(os.Args[2]) if err != nil { log.Fatal(err) } b, err := strconv.Atoi(os.Args[3]) if err != nil { log.Fatal(err) } // Calculate result result := operation(a, b) // Print result log.Println(result) } func ParseOperation(op string) (func(int, int) int, error) { switch op { case "+": return func(a, b int) int { return a + b }, nil case "-": return func(a, b int) int { return a - b }, nil case "*": return func(a, b int) int { return a * b }, nil case "/": return func(a, b int) int { return a / b }, nil case "%": return func(a, b int) int { return a % b }, nil default: return nil, fmt.Errorf("Unrecognized operation: %s", op) } }
After this refactor you can rerun the code to ensure the common case still works;
> go run . + 1 1 2
You might also consider writing a test for this auxillary function;
package main_test import ( "fmt" "github.com/stretchr/testify/assert" "github.com/stuartmscott/pncgo" "testing" ) func TestParseOperation(t *testing.T) { t.Run("Supported", func(t *testing.T) { for name, tt := range map[string]struct { operation string expected int }{ "add": { operation: "+", expected: 3, }, "subtract": { operation: "-", expected: 1, }, "multiply": { operation: "*", expected: 2, }, "divide": { operation: "/", expected: 2, }, "modulo": { operation: "%", expected: 0, }, } { t.Run(name, func(t *testing.T) { op, err := main.ParseOperation(tt.operation) assert.NoError(t, err) assert.Equal(t, tt.expected, op(2, 1)) }) } }) t.Run("Unsupported", func(t *testing.T) { op, err := main.ParseOperation("@") assert.Nil(t, op) assert.Error(t, fmt.Errorf("Unrecognized Operation: @"), err) }) }
> go test . ok github.com/stuartmscott/pncgo 0.149s
-
2022 April 13 20:03 stuartscott 8194¤ 261¤
Step 5: Reassess & Repackage
It is time to reassess how the software will be used and what other use cases we may want to support.
Several assumptions have been made so far which may not always hold true;
- All parameters are integers.
- All expressions only contain one operation.
- All operations take two parameters.
It is clear that the bulk of these assumptions are regarding how the input is parsed.
The first assumption is an easy fix since
float64
can represent both floating point and integer numbers.The second assumption tells us we need to vary the number of parameters given based on the operator.
Finally, we need something to represent both a single operation and multiple operations that can be resolved into an answer. In Go when we want to treat multiple different things as the same we use an interface so let's create one that defines the desired ability. Given the Mathematical context, "Expression" seems like an appropriate name, though you could also call it "Resolver", or "Resolveable";
package pncgo type Expression interface { Resolve() (float64, error) }
Since this interface is self-contained it can live in its own file, and the implementations of this interface can live in a new
expression
package.We can also name the root package after the repository and move
main.go
out and into a newcmd/pnc
package. Now when someone comes to the project and looks at the root package the first code they see is theExpression
interface; telling them the core purpose of the project is to handle expressions - makes sense for a calculator!We need something that can take the command line arguments and return an
Expression
. I find writting the usage first helps clarify the responsibilities of the implementation so if we assume we already have the parser, how willmain.go
change to use it?package main import ( "github.com/stuartmscott/pncgo/expression" "log" "os" ) func main() { // Parse expression from args exp, err := expression.Parse(os.Args[1:]) if err != nil { log.Fatal(err) } // Calculate result result, err := exp.Resolve() if err != nil { log.Fatal(err) } // Print result log.Println(result) }
Next, consider how the tests will need to be updated. First, how the tests invokes the code will change. Second, the tests will be moved and renamed to
expression/parser_test.go
so they are near the code being tested. Third, they will be expanded to cover the new use cases.package expression_test import ( "fmt" "github.com/stretchr/testify/assert" "github.com/stuartmscott/pncgo/expression" "testing" ) func TestParseOperation(t *testing.T) { t.Run("Supported", func(t *testing.T) { for name, tt := range map[string]struct { input []string expected float64 }{ "add_int": { input: []string{"+", "2", "1"}, expected: 3, }, "subtract_int": { input: []string{"-", "2", "1"}, expected: 1, }, "multiply_int": { input: []string{"*", "2", "1"}, expected: 2, }, "divide_int": { input: []string{"/", "2", "1"}, expected: 2, }, "modulo_int": { input: []string{"%", "2", "1"}, expected: 0, }, "add_float": { input: []string{"+", "2.1", "1.2"}, expected: 3.3, }, "subtract_float": { input: []string{"-", "2.1", "1.2"}, expected: 0.9000000000000001, // Floating point weirdness }, "multiply_float": { input: []string{"*", "2.1", "1.2"}, expected: 2.52, }, "divide_float": { input: []string{"/", "2.1", "1.2"}, expected: 1.7500000000000002, // Floating point weirdness }, "modulo_float": { input: []string{"%", "2.1", "1.2"}, expected: 0.9000000000000001, // Floating point weirdness }, "combination_subtractmulitply": { // Infix // (5 - 6) * 7 // Polish // - 5 6 = -1 // * -1 7 = -7 input: []string{"*", "-", "5", "6", "7"}, expected: -7, }, "combination_mulitplysubtract": { // Infix // 5 - (6 * 7) // Polish // * 6 7 = 42 // - 5 42 = -37 input: []string{"-", "5", "*", "6", "7"}, expected: -37, }, "combination_all": { // Polish // + 2 1 = 3 // - 3 1 = 2 // * 2 4 = 8 // / 8 1 = 8 // % 8 3 = 2 input: []string{"%", "/", "*", "-", "+", "2", "1", "1", "4", "1", "3"}, expected: 2, }, } { t.Run(name, func(t *testing.T) { exp, err := expression.Parse(tt.input) assert.NoError(t, err) result, err := exp.Resolve() assert.NoError(t, err) assert.Equal(t, tt.expected, result) }) } }) t.Run("Unsupported", func(t *testing.T) { exp, err := expression.Parse([]string{"@"}) assert.Nil(t, exp) assert.Error(t, fmt.Errorf("Unrecognized Operation: @"), err) }) }
Lastly, we can draw the rest of the owl and write the parser. For this we will define a few implementations of
Expression
; one for literals that simply resolve to their value, and one for each of the operators.package expression import ( "fmt" "github.com/stuartmscott/pncgo" "strconv" "unicode" ) func Parse(parts []string) (pncgo.Expression, error) { var stack []pncgo.Expression // Start from right hand side for i := len(parts) - 1; i >= 0; i-- { part := parts[i] if unicode.IsDigit(([]rune(part))[0]) { // If part is a number, add Literal to stack f, err := strconv.ParseFloat(part, 64) if err != nil { return nil, err } stack = append(stack, &Literal{f}) } else { // If part is an operator, add Operation to stack size := len(stack) switch part { case "+": if size < 2 { return nil, fmt.Errorf("Add takes 2 operands") } a, b := stack[size-1], stack[size-2] stack = append(stack[:size-2], &Add{a, b}) case "-": if size < 2 { return nil, fmt.Errorf("Subtract takes 2 operands") } a, b := stack[size-1], stack[size-2] stack = append(stack[:size-2], &Subtract{a, b}) case "*": if size < 2 { return nil, fmt.Errorf("Multiply takes 2 operands") } a, b := stack[size-1], stack[size-2] stack = append(stack[:size-2], &Multiply{a, b}) case "/": if size < 2 { return nil, fmt.Errorf("Divide takes 2 operands") } a, b := stack[size-1], stack[size-2] stack = append(stack[:size-2], &Divide{a, b}) case "%": if size < 2 { return nil, fmt.Errorf("Modulo takes 2 operands") } a, b := stack[size-1], stack[size-2] stack = append(stack[:size-2], &Modulo{a, b}) default: return nil, fmt.Errorf("Unrecognized operation: %s", part) } } } if len(stack) != 1 { return nil, fmt.Errorf("Unused Parameter") } return stack[0], nil }
-
2022 April 13 20:04 stuartscott 522¤
Conclusion
In total we created three packages (the root package "pncgo", the command line program "pnc" under "cmd", and the bulk of the implementation in "expression"), granted this is not a large codebase but had we started with some of the template projects we would be left with an overcomplicated structure with lots of unnecessary directories and package.
From here we have a strong base upon which we can add more operators, support features like reading input from a file, or create a graphical user interface.
-
-
-
-
Convey is made available by Aletheia Ware under the Terms of Service and Privacy Policy.
Convey is an open-source project released under the Apache 2.0 License and hosted on Github.
© 2021 Aletheia Ware LLC. All rights reserved.