AI times three—or how I made AI write a blog post for me

Writing a concurrency-safe hashmap in Go is dead easy, even an AI can do it! To prove this, I had three AI tools write this blog article, generate Go code, and create an opening image.

A short, human-generated introduction

AI tools are getting more and more sophisticated at generating stuff based on simple text prompts. While I played with these tools, an idea popped up inside my brain and decided to stay.

Can I have AI tools write a full blog article about Go?

I mean, like all of it: the text, the code, and an opening image, too.
And that's what I did.

I used three AI tools to write this article:

  • The opening image was generated by DALL-E 2 from a text prompt.
  • For the initial question/answer section, I used GPT-3 by OpenAI with the text-davinci-003 model.
  • For the code, I used GitHub Copilot, a code generator and code completion tool that you may already know.

I selected these tools for no particular reasons, other than that I had used them already. There are other great AI tools out there that can generate text, code, or images from a text prompt, like Tabnine, Replit Ghostwriter, Stable Diffusion, Hugging Face Transformers—you name them.

The only input from my side were the prompts for the AI engines. And I added a few comments to the output where necessary. I formatted prompts and comments as quotes, to distinguish them from the output of the AIs.

My prompts and my comments look like this. Everything else is AI output.

My goal was to get a complete article about a concurrency-safe hash table in Go. The generated article starts below this line. Have fun!


Cyan gopher dieselpunk mad scientist comic style

Cyan gopher dieselpunk mad scientist comic style

What is a hash table?

A hash table is a data structure that stores key-value pairs. It uses a hash function to compute an index into an array of buckets or slots, from which the desired value can be found. Hash tables are used for fast lookups, additions and deletions of data items, and can be used to implement associative arrays, sets, and caches.

How does a hash table work?

A hash table is a data structure that uses a hash function to map keys to values. The hash function takes a key as input and produces an index, which is used to locate the associated value in the table. The hash table is an array of buckets, each of which contains a linked list of key-value pairs. When a key is inserted into the hash table, the hash function is used to determine the index of the bucket in which the key-value pair should be stored. When a key is searched for, the hash function is used to determine the index of the bucket in which the key-value pair is stored, and then the linked list is searched for the key.

What are the advantages of using a hash table?

  1. Fast Lookup: Hash tables allow for fast lookup in near constant time. This is because the key is used directly to index the corresponding element, so no searching is required.

  2. Flexible Keys: Hash tables allow for almost any type of key to be used. This is because the keys are hashed and stored in the table.

  3. Dynamic Set Size: Hash tables can grow and shrink dynamically, so the size of the set does not need to be known ahead of time.

  4. Cache-Friendly: Hash tables are cache-friendly, meaning that they can be stored in memory and accessed quickly.

When did Martin Scorcese last use a hash table, and what for?

Martin Scorsese has not used a hash table in any of his films. Hash tables are primarily used in computer programming, so it is unlikely that he has ever used one.

Now, that's a smart answer. Not bad, little AI. Then you surely know the following, too:

What is the best hash table implementation in Go?

The best hash table implementation in Go is the built-in map type. It is fast, efficient, and easy to use. It is also thread-safe, meaning that multiple goroutines can safely access the same map concurrently.

Nope, the last sentence is not quite true. Go's standard map type is not thread-safe, for performance reasons, and because Go's concurrency model is based on message passing, not shared memory. But that's a minor detail. Let's move on and ask Copilot to write a hash table for us. The first comment is the prompt:

Package cohash implements a concurrency-safe, generic hash table using type parameters. The key must be a comparable type, the value can be any type.

package cohash

import (
	"sync"
)

A Table is a concurrency-safe hash table.

type Table[K comparable, V any] struct {
	mu sync.RWMutex
	m  map[K]V
}

New returns a new Table.

func New[K comparable, V any]() *Table[K, V] {
	return &Table[K, V]{m: make(map[K]V)}
}

Set sets the value for key k to v.

func (t *Table[K, V]) Set(k K, v V) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.m[k] = v
}

Get returns the value for key k, or false if k is not present.

func (t *Table[K, V]) Get(k K) (V, bool) {
	t.mu.RLock()
	defer t.mu.RUnlock()
	v, ok := t.m[k]
	return v, ok
}

Delete deletes the value for key k.

func (t *Table[K, V]) Delete(k K) {
	t.mu.Lock()
	defer t.mu.Unlock()
	delete(t.m, k)
}

Len returns the number of elements in the table.

func (t *Table[K, V]) Len() int {
	t.mu.RLock()
	defer t.mu.RUnlock()
	return len(t.m)
}

Keys returns a slice containing all keys in the table.

func (t *Table[K, V]) Keys() []K {
	t.mu.RLock()
	defer t.mu.RUnlock()
	var keys []K
	for k := range t.m {
		keys = append(keys, k)
	}
	return keys
}

Values() contains a subtle bug. Can you find it?

Values returns a slice containing all values in the table.

func (t *Table[K, V]) Values() []V {
	t.mu.RLock()
	defer t.mu.Unlock()
	var values []V
	for _, v := range t.m {
		values = append(values, v)
	}
	return values
}

Clear removes all key-value pairs from the table.

func (t *Table[K, V]) Clear() {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.m = make(map[K]V)
}

Iter calls f on each key-value pair in the table.

func (t *Table[K, V]) Iter(f func(k K, v V)) {
	t.mu.RLock()
	defer t.mu.RUnlock()
	for k, v := range t.m {
		f(k, v)
	}
}

func main contains an example of how to use the hash table.

func main() {

Create a new hash table.

	t := New[string, int]()

Set some values.

	t.Set("a", 1)
	t.Set("b", 2)
	t.Set("c", 3)

Get a value.

	v, ok := t.Get("b")
	if !ok {
		panic("b not found")
	}
	println(v) // prints "2"

Delete a value.

	t.Delete("b")

Iter over all key-value pairs.

	t.Iter(func(k string, v int) {
		println(k, v)
	})
}

And the second comment is the prompt for the unit test:

Wait, there is no second comment in my previous prompt, but Copilot simply moved on to creating unit tests. Ok, why not.

Package cohashtest provides tests for the cohash package.

package cohashtest

import (
	"testing"

	"github.com/dhconnelly/cohash"
)

TestTable tests the Table type.

func TestTable(t *testing.T) {
	t.Run("Set", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		if v, ok := tbl.Get("a"); !ok || v != 1 {
			t.Fatal("expected a=1")
		}
	})

	t.Run("Get", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		if _, ok := tbl.Get("a"); ok {
			t.Fatal("expected a not found")
		}
		tbl.Set("a", 1)
		if v, ok := tbl.Get("a"); !ok || v != 1 {
			t.Fatal("expected a=1")
		}
	})

	t.Run("Delete", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		tbl.Delete("a")
		if _, ok := tbl.Get("a"); ok {
			t.Fatal("expected a not found")
		}
	})

	t.Run("Len", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		if tbl.Len() != 0 {
			t.Fatal("expected empty table")
		}
		tbl.Set("a", 1)
		if tbl.Len() != 1 {
			t.Fatal("expected len=1")
		}
	})

	t.Run("Keys", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		tbl.Set("b", 2)
		tbl.Set("c", 3)
		keys := tbl.Keys()
		if len(keys) != 3 {
			t.Fatal("expected 3 keys")
		}
		for _, k := range keys {
			if _, ok := tbl.Get(k); !ok {
				t.Fatal("expected key to be present")
			}
		}
	})

This one is wrong. It's essentially the same code as the Keys test. But Get(v) is incorrect. To test if all values are present in the map, the test needs to iterate over the map to find each value.

	t.Run("Values", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		tbl.Set("b", 2)
		tbl.Set("c", 3)
		values := tbl.Values()
		if len(values) != 3 {
			t.Fatal("expected 3 values")
		}
		for _, v := range values {
			if _, ok := tbl.Get(v); !ok {
				t.Fatal("expected value to be present")
			}
		}
	})

	t.Run("Clear", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		tbl.Set("b", 2)
		tbl.Set("c", 3)
		tbl.Clear()
		if tbl.Len() != 0 {
			t.Fatal("expected empty table")
		}
	})

	t.Run("Iter", func(t *testing.T) {
		t.Parallel()
		tbl := cohash.New[string, int]()
		tbl.Set("a", 1)
		tbl.Set("b", 2)
		tbl.Set("c", 3)
		var keys []string
		tbl.Iter(func(k string, v int) {
			keys = append(keys, k)
		})
		if len(keys) != 3 {
			t.Fatal("expected 3 keys")
		}
		for _, k := range keys {
			if _, ok := tbl.Get(k); !ok {
				t.Fatal("expected key to be present")
			}
		}
	})
}


Ok, this is me again. No AI-generated text beyond this point.

What are my takeaways?

It is amazing to see how good AI tools have become. On the other hand, there is still room for improvement. While I was typing away English text in VSCode, Copilot did not stop suggesting continuations of my sentences, and sometimes they were so off the mark that it was outright funny.

I must admit that my prompts might be quite challenging for an AI. After all, I asked it to carve out a whole package that implements a complete hash table type, and this implicitly includes all the methods a client would expect to find.

When I asked to create a main func, Copilot did not stop there and generated the tests as well. That's completely fine, because what's the delete key for?

I also had a hard time getting code that uses type parameters (a.k.a generics) for the key and value types. Most of the suggestions used interface{}, which indicates that most of the Go code that Copilot was trained with was written before generics were added to the language. The saying “You can't teach an old dog new tricks” seems to apply to AIs as well.

Last not least, two bugs sneaked into the code. This is expected because an AI does not generate code based on strict and proven algorithms. It rather “dreams up” code based on the code it has been trained with. So better get good at writing ruthless unit tests before thinking about having an AI write code for you.

Speaking of training data, I have the feeling that neither OpenAI nor Stability AI (the makers of Stable Diffusion) have used Go Gopher images to train their models. The gopher in the opening image is the best I could get. (Greetings to Markus from golang.dk who faces the same problem.) I also tried DALL-E's outpainting mode by supplying it with an Applied Go Gopher image, but the results were not exactly to my taste.

This Go Playground page contains the complete, plain code with bug fixes. I also included the test code although the playground does not run unit tests. Feel free to copy the code to local files if you want to examine it further.

TL;DR

  • AIs have reached an amazing level of proficiency in generating text, images, or code.
  • AIs can only dream up output based on the data they were trained with. A HITL (Human In The Loop) is still needed for ensuring that the results are not only correct but also what's been asked for.

Further reading

My first excursion into AI with Go resulted in creating a single artificial neuron. That's a start, isn't it? And it works!

Get the Applied Go Weekly Newsletter

Stay up to date in the world of Go. Get a hand-curated, commented list of articles and projects, and more, every week right in your inbox.

By subscribing, you agree to the privacy policy.

Powered by Buttondown.

Share
Like this article? Tell your friends!