SoFunction
Updated on 2025-03-05

Detailed explanation of Golang's implementation of an unreplicable type

How to copy an object

Regardless of the code analysis provided by the IDE and static analysis tools such as govet, almost all types in golang can be copied.

// Basic scalar types and pointersvar i int = 1
iCopy := i
str := "string"
strCopy := str
 
pointer := &i
pointerCopy := pointer
iCopy2 := *pointer // Copy after dereference 
// Structure and arrayarr := [...]int{1, 2, 3}
arrCopy := arr
 
type Obj struct {
    i int
}
obj := Obj{}
objCopy := obj

In addition to these, golang also has functions and reference types (slice, map, interface). These types can also be copied, but slightly different:

func f() {...}
f1 := f
f2 := f1
 
(f1, f2) // 0xabcdef 0xabcdef The printed value is the same(&f1 == &f2) // false Although the value is the same, it is indeed two different variables

There is no real copy of the three copies of f code here. F1 and f2 both point to f, and there will always be only one copy of the code for f. Map, slice and interface are similar to:

m := map[int]string{
    0: "a",
    1: "b",
}
mCopy := m // Both reference the same datamCopy[0] := "unknown"
m[0] == "unknown" // True
// The copy of slice is the same as the map

The interface is relatively alternative, and its behavior needs to be divided into two situations:

s := "string"
var i1 any = s
var i2 any = s
// When assigning values ​​of non-pointer and interface types to the interface, the original object will be copied. 
s := "string"
var i1 any = s
var i2 any = i2
// When the interface is assigned to the interface, the underlying referenced data will not be copied, i1 will copy s, and i2 will have a copy of s and i1 at this time. 
ss := "string but pass by pointer"
var i3 any = &ss
var i4 any = i3
// Both i3 and i4 refer to ss. At this time, ss is not copied, but the value of the pointer to ss is copied twice

The above results will be disturbed by compilation optimization to a certain extent. For example, in rare cases, the compiler can confirm that the value assigned to the interface has never been modified and its life cycle is not longer than the source object, so it may not be copied.

So here is a tip: if the data to be assigned to the interface is relatively large, it is best to assign to the interface in the form of a pointer. Copying a pointer is more efficient than copying a large amount of data.

Why not copying

As you can see from the previous section, you will "came trouble" in some cases when copying is allowed. for example:

1. The problem of shallow copy is easy to occur, such as the problem of shallow copying of map and slice in the example, which may cause unexpected modification of data.

2. Accidentally copied a large amount of data, resulting in performance problems

3. The replica is used incorrectly where shared state is needed, resulting in inconsistent states, which leads to serious problems. For example, copying a lock and using its replica will cause a deadlock.

4. According to business or other needs, only one instance is allowed for a certain type of object, and copying is obviously prohibited at this time.

Obviously it is reasonable to ban copying in some cases, which is why I wrote this article.

However, the specific analysis of the specific situation does not mean that copying is the source of all evil. When should copy be supported and when should be prohibited, it should be based on your actual situation.

Runtime detection implements prohibition of replication

I want to prohibit a certain type from being copied in other languages. There are many methods, so use C++ to give an example:

struct NoCopy {
    NoCopy(const NoCopy &) = delete;
    NoCopy &operator=(const NoCopy &) = delete;
};

Unfortunately, this is not supported in golang.

In addition, because golang does not have operator overloading, it is difficult to intercept during the assignment stage, so our focus is on "it can be detected as soon as possible after copying."

So we first implement the function of reporting an error after the object is copied. Although it is not as elegant as the ability to prohibit copying during the compilation period of C++, it is also considered to have implemented functions, at least nothing is stronger.

Preliminary attempt

So how until the object is copied? It's very simple, just look at its address. If the address is the same, it must be the same object. If it is different, it means that a new object has been copied.

Following this idea, we need a mechanism to save the address of the object when it was first created and compare it later, so the first version of the code was born:

import "unsafe"
 
type noCopy struct {
    p uintptr
}
 
func (nc *noCopy) check() {
    if uintptr((nc)) !=  {
        panic("copied")
    }
}

The logic is relatively clear. Each time you call check to check whether the address and save address of the current caller are the same. If it is different, panic.

Why is there no method of this type created? Because we cannot know the address when we were created by other types, we have to let other types that use noCopy to do this.

When using it, you need to embed noCopy into your own struct. Be careful not to embed it in the form of a pointer:

type SomethingCannotCopy struct {
    noCopy
    ...
}
 
func (s *SomethingCannotCopy) DoWork() {
    ()
    ("do something")
}
 
func NewSomethingCannotCopy() *SomethingCannotCopy {
    s := &SomethingCannotCopy{
        // Some initializations    }
    // Bind address     = (&)
    return s
}

Pay attention to the initialization part of the code, here we need to bind the address of the noCopy object. Runtime detection is now possible:

func main() {
    s1 := NewSomethingCannotCopy()
    pointer := s1
    s1Copy := *s1 // This is actually copied, but it needs to be called to detect it    () // Print out information normally    () // panic
}

Explain the principle: When SomethingCannotCopy is copied, noCopy will also be copied. Therefore, the address of the copied noCopy is different from the original one, but the p recorded internally is the same, so panic will be triggered when the copied noCopy object calls the check method. This is also why you should not embed it in the form of a pointer.

The function is implemented, but the code is too ugly and it is very coupled: as long as noCopy is used, the instance of noCopy must be initialized while creating the object. The initialization logic of noCopy will invade the initialization logic of other objects, and such a design is unacceptable.

Better implementation

So is there a better implementation? There are yes, and it is in the standard library.

The standard library's semaphore is prohibited and is more stringent than Mutex, because it is easier to cause deadlocks and crashes than replicate locks, so the standard library adds runtime dynamic checks.

The main code is as follows:

type Cond struct {
    // L is held while observing or changing the condition
    L Locker
    ...
    // Copy check    checker copyChecker
}
 
// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
        return &Cond{L: l}
}
 
func (c *Cond) Signal() {
    // Check if you are copied    ()
    runtime_notifyListNotifyOne(&)
}

Checker implements runtime detection to detect whether it is copied, but it does not require special processing of this checker during initialization. What method is used to do this?

Look at the code:

type copyChecker uintptr
 
func (c *copyChecker) check() {
    if uintptr(*c) != uintptr((c)) && // step 1
            !((*uintptr)(c), 0, uintptr((c))) && // step 2
            uintptr(*c) != uintptr((c)) { //step 3
        panic(" is copied")
    }
}

It looks very complicated, and even atomic operations are coming. What is this? But don't be afraid, I'll understand after you finish it.

First, the first call after checker is initialized:

  • When check is called for the first time, the value of c must be 0, and at this time c has a real address, so step 1 fails and enter step 2;
  • Use atomic operations to set the value of c to your own address value. Note that only when the value of c is 0 can the setting be completed, because the value of c is 0 here, so the exchange is successful, step 2 is False, and the judgment process ends directly;
  • Because it is not ruled out that there are other goroutines that are using this checker to do the test, step 2 will fail, and this is to enter step 3;
  • step 3 again compares whether the value of c and its own address is the same. The same means that multiple goroutines share a checker and no copying occurs, so the detection will not panic.
  • If the comparison of step 3 is found to be unequal, it means it has been copied and directly panic

Then let's look at the checker process in other cases:

  • At this time, the value of c is not 0. If no copying occurs, the result of step 1 is False, and the judgment process is over and will not panic;
  • If the value of c is different from its own address, it will enter step 2. Because the value of c is not 0 here, the expression result must be True, so it enters step 3;
  • step 3 is the same as step 1, and the result is True. The different address is copied. At this time, the statement in if will be executed, so panic.

It's so troublesome, in fact, it's just to be able to cleanly initialize it. In this way, any type only needs to bring checker as its own field, and don't worry about how it is initialized.

There is another small question, why does setting the checker value requires atomic operation, but it doesn't need to read it?

Because reading a uintptr value only has one instruction on modern x86 and arm processors, either reading outdated values ​​or reading the latest values ​​will not be read, wrong or half-written incomplete values. For the case of reading old values ​​(mainly when check is called for the first time), step 3 will also do further checks, so it will not affect the entire detection logic. "Compare and Exchange" obviously cannot be completed in one instruction. If the intermediate step is interrupted, the result of the entire operation is likely to be wrong, which affects the entire detection logic, so it must be operated with atoms.

So is it OK to use it when reading? Of course, it is OK, but first, it still cannot avoid step 3 detection. You can think about why; second, atomic operations will bring performance losses compared to direct reading. This is not worth the cost if the correctness is guaranteed without using atomic operations.

performance

Because it is runtime detection, we have to see how much impact it will have on performance. We use an improved version of checker.

type CheckBench struct {
    num uint64
    checker copyChecker
}
 
func (c *CheckBench) CheckCopy() {
    ()
    ++
}
 
// No testingfunc (c *CheckBench) NoCheck() {
    ++
}
 
func BenchmarkCheckBench_NoCheck(b *) {
    c := CheckBench{}
    for i := 0; i < ; i++ {
        for j := 0; j < 50; j++ {
            ()
        }
    }
}
 
func BenchmarkCheckBench_WithCheck(b *) {
    c := CheckBench{}
    for i := 0; i < ; i++ {
        for j := 0; j < 50; j++ {
            ()
        }
    }
}

The test results are as follows:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_NoCheck-8           17689137                68.36 ns/op
BenchmarkCheckBench_WithCheck-8         17563833                66.04 ns/op

Almost negligible, because we don't have replication here, so almost every detection passes, which is very friendly to the CPU's branch prediction, so the performance loss is almost negligible.

So we add some confusion to the CPU to make branch predictions not that easy:

func BenchmarkCheckBench_WithCheck(b *) {
    for i := 0; i < ; i++ {
        c := &CheckBench{}
        for j := 0; j < 50; j++ {
            ()
        }
    }
}
 
func BenchmarkCheckBench_NoCheck(b *) {
    for i := 0; i < ; i++ {
        c := &CheckBench{}
        for j := 0; j < 50; j++ {
            ()
        }
    }
}

Now branch prediction is not that easy and you have to pay more attention to using atomic when initializing, and the test results will become like this:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_WithCheck-8         15552717                74.84 ns/op
BenchmarkCheckBench_NoCheck-8           26441635                44.74 ns/op

It will be almost 40% slower. Of course, the actual code will not be so extreme, so the worst may only have a 20% impact, which is usually not a performance bottleneck. Runtime detection of whether there is any impact is also required for tuberculosis profile.

Pros and Cons

advantage:

  • As long as you call check, you can definitely check whether it is copied
  • Simple

shortcoming:

  • Check is required in all methods. If the new method is forgotten, it cannot be detected.
  • The copy operation can only be detected on the new object that is copied. It is always OK to check on the object. This is not strictly prohibited for copying, but it is OK to accept it.
  • If you only copy the object and do not call any method on the object, copying cannot be detected, this is rare.
  • There is potential performance loss, although it can often be fully optimized and the loss is not that exaggerated

Static detection implements prohibition of replication

There are many disadvantages of dynamic detection. Can copying be prohibited during the compilation period like C++?

Static detection is achieved using the Locker interface that cannot be replicated

It's OK, but it has to be combined with static code detection tools, such as the included go vet. Look at the code:

// Implement the interfacetype noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
 
type SomethingCannotCopy struct {
    noCopy
}

That's fine, no need to add any other code. Explain the principle: No type implemented should be copied, and static code detection will detect these situations and report an error.

Therefore, codes like the following cannot be detected by static code:

func f(s SomethingCannotCopy) {
    // An error is reported because the parameters will cause copying    // Returning to SomethingCannotCopy is not OK}
 
func (s SomethingCannotCopy) Method() {
    // An error is reported because a non-pointer type receiver will cause replication}
 
func main() {
    s := SomethingCannotCopy{}
    sCopy := s // Report an error    sInterface := any(s) // Report an error    sPointer := &amp;s // OK
    sCopy2 := *sPointer // Report an error    sInterface2 := any(sPointer) // OK
    sCopy3 := *(sInterface2.(*SomethingCannotCopy)) // Report an error}

It basically covers the places where copy operations will occur, and detection can basically be completed during the compilation period.

If you skip go vet and use go run or go build directly, then the above code can be compiled and run normally.

Pros and Cons

Because there is only static detection, there is no runtime overhead, so there is no need to spend money on performance. Let’s take a look at the advantages and disadvantages of this solution.

advantage:

  • The implementation is very simple, the code is very concise, and basically non-invasive
  • Rely on static detection, does not affect runtime performance
  • Golang comes with detection tool: go vet
  • More detectable cases than runtime detection

shortcoming:

  • The biggest disadvantage is that although static detection will report an error, it can still compile and execute normally.
  • Not every test environment and CI are equipped with static detection, so it is difficult to force the type to be not copied.
  • It will lead to type implementation, however, many times our type is not a lock-like resource. Use this interface only for static detection, which will bring the risk of code being misused.

This solution is also used by the standard library, it is recommended to read this carefullyissuediscussion in.

Going further

After looking at the two solutions of runtime detection and static detection, we will find that these practices are somewhat problematic and unsatisfactory.

So we still need to pursue a more useful and more in line with the golang style. Fortunately, this approach exists.

Encapsulation using package and interface

First, we create a worker package, which defines a Worker interface, and the data in the package is provided externally in the form of a Worker interface:

package worker
 
import (
    "fmt"
)
 
// Only provide interfaces to access datatype Worker interface {
    Work()
}
 
// The internal type is not exported and is used externally in the form of an interface.type normalWorker struct {
    // data members
}
func (*normalWorker) Work() {
    ("I am a normal worker.")
}
func NewNormalWorker() Worker {
    return &amp;normalWorker{}
}
 
type specialWorker struct {
    // data members
}
func (*specialWorker) Work() {
    ("I am a special worker.")
}
func NewSpecialWorker() Worker {
    return &amp;specialWorker{}
}

The worker package only provides Worker interfaces. Users can use NewNormalWorker and NewSpecialWorker to generate different types of workers. Users do not need to care about the specific return type, just use the obtained Worker interface.

If you do this, you cannot see two types: normalWorker and specialWorker outside the worker package, so you cannot retrieve the data referenced by the interface by reflection and type assertion; because we pass the pointer to the interface, the source data will not be copied; at the same time, we mentioned in the first section that assigning one interface to another (you can only do this outside the worker package), the underlying referenced data will not be copied, so there will never be any copying behavior on these two types outside the package.

Therefore, the following code is impossible to compile:

func main() {
    w := ()
    // Not visible outside the worker package, so the compilation error    wCopy := *(w.(*))
    ()
}

Pros and Cons

This implements prohibited copying outside the worker package. Let’s take a look at the advantages and disadvantages.

advantage:

  • No additional static checking tools are required to perform checks before compiling the code
  • No need to dynamically detect whether it is copied at runtime
  • It will not implement the interface type you don't need, which causes polluting method sets
  • Comply with customary practices in golang development

shortcoming:

  • Instead of making the type itself uncopyable, it blocks most of the cases that may lead to copying by encapsulation.
  • These worker types are visible in the package. If you do not pay attention to the code in the package, it may cause the values ​​of these types to be copied. Therefore, either the Woker interface is used in the package, or refer to the previous section to add static checks.
  • In some scenarios, the interface is not needed or the performance requirements cannot be used. This approach is not feasible. For example, most of the types in the standard library sync are exposed to the external use for performance.

Overall, this solution is the lowest cost.

Summarize

Now we have three ways to prevent our types from being copied:

  • Runtime detection
  • Static code detection
  • Avoid exposing types through interface encapsulation, thus avoiding being copied

There are three options in total, and the difficulty in choosing seems to be about to occur. Don't worry, let's take a look at how the standard library is made:

  • The standard library uses both scenario one and scenario two, because the designer does not want the condition variables to be copied.
  • , and the use of plan 2, need to cooperate with go vet
  • Solution 3 is the most widely used in the standard library, but most of them are designed and encapsulated, not to prohibit copying, but copying the Hash and Cipher under the crypto package is indeed meaningless and will cause misuse. These problems are avoided through Solution 3.

Overall, the first choice should be Scheme 3; but there are also times when using Scheme 2, such as those synchronization mechanisms in the sync package; the least use Scheme 1, and try not to design similar codes as much as possible.

Another thing to note is that if there are fields in your type,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,

Finally, I just want to say that golang's language skills are too simple. It is not realistic to rely solely on language features to achieve the function of prohibiting copying. It still needs to rely on "design".

The above is a detailed explanation of Golang's unreplicable type implementation. For more information about Golang's unreplicable type, please pay attention to my other related articles!