Shared state is relatively easy to understand and use, but may produce bugs that are so obscure that it is difficult to track. Especially when our data structure is only partially passed by reference. Slicing is such a good example. I will give a more detailed explanation in the future.
Immutable data structures are very useful when dealing with data that has undergone multi-stage transformation or state. Immutable only means that the original structure cannot be changed, and each new copy of the structure is created with a new property value.
Let's look at a simple example:
type Person struct { Name string FavoriteColors []string }
Obviously, we can instantiate a Person and change its properties as we like. In fact, there is nothing wrong with doing this. However, changing these shared data copies in certain postures can lead to less observable bugs when you deal with more complex, nested data structures that pass references and slices, or using channels to pass replicas.
Why haven't I encountered such a problem before?
If you don't use the channel heavily or the code is basically executed serially, since by definition there is only one operation at a time that can act on the data, you are unlikely to encounter these indistinct bugs.
Furthermore, in addition to avoiding bugs, immutable data structures have other advantages:
- Since the state is never updated in place, this is very useful for general debugging and recording each transformation step for subsequent monitoring
- The ability to undo or “rewind time” is not only possible, but also a piece of cake, with just one assignment operation
- Shared state is widely considered a bad practice because correct and secure implementation requires performance loss and careful careful setup/testing of memory locks
Getter and Wither
Getter returns data, setter changes data, and wither creates a new state.
Based on getter and wither, we can accurately control the properties that can be changed. This also provides us with an effective way to record transformations (subsequently).
The new code is as follows:
type Person struct { name string favoriteColors []string } func (p Person) WithName(name string) Person { = name return p } func (p Person) Name() string { return } func (p Person) WithFavoriteColors(favoriteColors []string) Person { = favoriteColors return p } func (p Person) FavoriteColors() []string { return }
The key points to be noted are as follows:
- Person's properties are all private, so external packages cannot bypass methods provided by Person to access their properties.
- Person's method does not receive *Person. This ensures that the structure is passed through the value, and the returned value is also the value
- Note: I used "With" instead of "Set" to show that it is important that the return value and the original object is not changed like the call setter
- For the code under the same package, all properties are still accessible (that is, they can be changed). We should never interact with attributes directly, but we should always stick to the use of methods under the same package.
- Each wither returns Person, so they are concatenable
me := Person{}. WithName("Elliot"). WithFavoriteColors([]string{"black", "blue"}) ("%+#v\n", me) // {name:"Elliot", favoriteColors:[]string{"black", "blue"}}
Process slices
So far it's not perfect, because for favorite colors we're returning is slices. Since slices are passed through references, let's take a look at this bug that will be ignored if you are not careful:
func updateFavoriteColors(p Person) Person { colors := () colors[0] = "red" return p } func main() { me := Person{}. WithName("Elliot"). WithFavoriteColors([]string{"black", "blue"}) me2 := updateFavoriteColors(me) ("%+#v\n", me) ("%+#v\n", me2) } // {name:"Elliot", favoriteColors:[]string{"red", "blue"}} // {name:"Elliot", favoriteColors:[]string{"red", "blue"}}
We want to change the first color, but we have changed the me variable in conjunction. Since this does not cause the code to run in complex applications, trying to find such a change is quite annoying and time-consuming.
One solution is to make sure we never assign values through indexes, but always allocate a new slice:
func updateFavoriteColors(p Person) Person { return (append([]string{"red"}, ()[1:]...)) } // {name:"Elliot", favoriteColors:[]string{"black", "blue"}} // {name:"Elliot", favoriteColors:[]string{"red", "blue"}}
In my opinion, this is a bit clumsy and error-prone. A better way is to not return the slice in the beginning. Expand our getter and wither to operate on elements only (not the entire slice):
func (p Person) NumFavoriteColors() int { return len() } func (p Person) FavoriteColorAt(i int) string { return [i] } func (p Person) WithFavoriteColorAt(i int, favoriteColor string) Person { = append([:i], append([]string{favoriteColor}, [i+1:]...)...) return p }
Translator's note: The above code is wrong. If the capacity is greater than i, the favoriteColors of the copy will be changed locally. See the counterexample. A little adjustment will be made to achieve the correct implementation.
Now we can use it with confidence:
func updateFavoriteColors(p Person) Person { return (0, "red") }
For more information about slices, please refer to this awesome wiki:/golang/go/wiki/SliceTricks
Constructor
In some cases, we will assume that the default value of the structure is reasonable. However, it is strongly recommended that constructors always be created, and once we need to change the default value in the future, we only need to change one place:
func NewPerson() Person { return Person{} }
You can instantiate Person as you like, but your personal preference always uses setters to perform state transformations to maintain code consistency:
func NewPerson() Person { return Person{}. WithName("No Name") }
Interface
Until now, we are still using public structures. At the mercy of these structural methods, plus creating mocks can cause unexpected side effects, it can be painful to test.
We can create an interface with the same name and rename the corresponding structure to person to make it private:
type Person interface { WithName(name string) Person Name() string WithFavoriteColors(favoriteColors []string) Person NumFavoriteColors() int FavoriteColorAt(i int) string WithFavoriteColorAt(i int, favoriteColor string) Person } type person struct { name string favoriteColors []string }
We can now create test mock just by rewriting the logic we want to replace:
type personMock struct { Person receivedNewColor string } func (m personMock) WithFavoriteColorAt(i int, favoriteColor string) Person { = favoriteColor return m }
The test code sample is as follows:
mock := personMock{} result := updateFavoriteColors(mock) result.(personMock).receivedNewColor // "red"
Record changes
As I said earlier, full state transitions are very beneficial for debugging, and we can use the hook to capture all or part of the transformation process:
func (p person) nextState() Person { ("nextState: %#+v\n", p) return p } func (p person) WithName(name string) Person { = name return () // <- Use "nextState" whenever you return. }
For more complex logic or personal preferences, you can also use the defer method:
func (p person) WithFavoriteColors(favoriteColors []string) Person { defer func() { () }() = favoriteColors return p }
This way you can see:
nextState: {name:"No Name", favoriteColors:[]string(nil)} nextState: {name:"Elliot", favoriteColors:[]string(nil)} nextState: {name:"Elliot", favoriteColors:[]string{"black", "blue"}}
You can add more information like this. For example, timestamps, stack traces, and other custom context information make debugging easier.
History and rollback
In addition to printing changes, we can also collect these states as history:
type Person interface { // ... AtVersion(version int) Person } type person struct { // ... history []person } func (p *person) nextState() Person { = append(, *p) return *p } func (p person) AtVersion(version int) Person { return [version] } func main() { me := NewPerson(). WithName("Elliot"). WithFavoriteColors([]string{"black", "blue"}) // We discard the result, but it will be put into the history. updateFavoriteColors(me) ("%s\n", (0).Name()) ("%s\n", (1).Name()) } // No Name // Elliot
This is very conducive to final review. Recording the history of all log prints is also useful for handling subsequent exception scenarios. If not required, let the history die with the instance.
The above is all the content of this article. I hope it will be helpful to everyone's study and I hope everyone will support me more.