...

Golang Queue: Master through the new TDD Approach in 2024

Rate this post

Introduction

Golang Queue a fundamental data structure, manages elements based on the First-In-First-Out (FIFO) principle. Learning its implementation through the Test-Driven Development (TDD) approach offers a structured method to comprehend and build a queue in Go.

TDD involves a cyclical process of writing tests before actual implementation. Applying this methodology to develop a queue in Go not only reinforces understanding of the data structure but also ensures the creation of reliable and robust code.

By leveraging TDD, developers can gradually construct a queue in Go by first defining test cases that delineate expected behaviors. Subsequently, implementing code to pass these tests incrementally solidifies the functionality and correctness of the queue implementation.

Implementing a queue in Go via TDD enhances comprehension of both the queue’s behavior and Go’s testing framework. This method fosters a systematic and disciplined approach, facilitating the creation of a dependable queue implementation while honing one’s proficiency in Go programming and software development methodologies.

What is test driven development(TDD)?
It means code development by writing test cases first.
Key steps in TDD are as below:-
1) Write a test case first, and run the test cases, here compilation failures are also considered as failed.
2) Write minimal code to pass the test cases.
3) Refactor the code.
Keep repeating the above steps until a sufficient number of test cases are passed.
Learn more about tdd from test-driven-development.

Queue is a FIFO data structure. FIFO means first in, first out.

Golang Queue
Golang Queue

Implementing Golang Queue using TDD

Implementing a Queue in Go following the Test-Driven Development (TDD) approach involves a systematic process. Begin by outlining test cases that define expected queue behaviors. Initially, create failing tests that address queue functionalities like enqueue, dequeue, size, and empty status.

Proceed by incrementally writing code to fulfill these tests, validating the queue’s functionality step by step. TDD ensures a robust implementation as each test guides the incremental development, refining, and validating queue operations.

This iterative cycle of writing tests, implementing code, and verifying functionality fosters a reliable and efficient queue structure while enhancing proficiency in Go programming and testing methodologies.

Major Steps of Queue implementation in Go

To implement a queue in go, we need to know the basic operations of the queue in go, they are as follows:-
New[T any](size int) *Queue[T] -> Creates queue of type T of capacity size
Capacity() -> total number of elements it can hold
Len() int -> total number of elements currently in the queue
IsEmpty() bool -> Is the queue empty?
IsFull() bool -> is the queue full?
Front() *T-> head element of queue
Enqueue(val T) -> Enqueue an element into the queue(at the back)
Dequeue() -> Remove an element from the front

Operations of Queue in Go
Operations of Queue in Go

Implementation of Queue in Go
basic setup
create a queue folder mkdir queue
initialize go module -> go mod init queue
create queue.go with the package name queue
create queue_test.go with the package name queue

Now start writing test cases for Queue in Go
first test case


func TestSize(t *testing.T) {
	q := New[int](10)
	if q.Capacity() != 10 {
		t.Errorf("invalid queue created")
	}
}

run using go test . or if in visual studio code hover mouse on TestSize function and click on run test
on running go test . we get compilation error because New function is not defined.

We write the New function below

func New[T any](size int) *Queue[T] {
	return &Queue[T]{data: make([]T, 0, size)}
}

Note: refer to my generics post in case facing difficulty due to generics

Again run using
go test .
Still, we get compilation errors due to the Capacity function not being defined. Let us implement this as well

func (q *Queue[T]) Capacity() int {
	return cap(q.data)
}

Now running test cases again using go test ., this time test case passes
If we change q.Capacity() != 8, it will fail which is fine since our queue capacity is 10

Now, we will move on to a new test case for isEmpty()

func TestIsEmpty(t *testing.T) {
	q := New[int](10)
	if !q.IsEmpty() {
		t.Errorf("invalid queue created")
	}
}

Since we have not implemented isEmpty running the test case will fail at the compilation stage.
That is fine, so we will first implement isEmpty() as below

func (q *Queue[T]) IsEmpty() bool {
return len(q.data) == 0

}

Now we will run the test again using go test . since we have not enqueued any element in the queue will be empty initially and our test case will pass, we can think of adding an element into the queue and check again whether the queue is empty or not, take this as your homework
Also, once the test cases are passed we should think of refactoring the code. The above code can be refactored as below

func (q *Queue[T]) Len() int {
	return len(q.data)
}

func (q *Queue[T]) IsEmpty() bool {
	return q.Len() == 0
}
we need to implement Len() function, so it is better to implement it first and resue it in isEmpty() as above

Now, we will test the enqueue functionality of Queue in Go

func TestEnqueue(t *testing.T) {
	q := New[int](10)
	q.Enqueue(1)

	if q.Len() != 1 {
		t.Errorf("invalid queue length, expected: %v, got: %v", 1, q.Len())
	}
	front, err := q.Front()
	if err != nil {
		t.Errorf("got error %v while fetching front element", err)
	}
	if *front != 1 {
		t.Errorf("expected: %v, got: %v", 1, *front)
	}
}

Again, when we run go test . or specifically for TestEnqueue go test -run ^TestEnqueue$ queue
we see that test fails at compilation stage(earlier we mentioned that compilation failures are failures), so we will first implement Enqueue() as below

func (q *Queue[T]) Enqueue(val T) error {
	q.data = append(q.data, val)
	return nil
}

Note that we just need to write enough code to make our test case pass, in this manner complete code will be developed automatically once all test cases are passed, in some time we will also know how TDD can help us find bugs in our code and develop queue in Go.

We should continue to run test cases using go test -run ^TestEnqueue$ queue
again we see compilation errors due to q.Front() not implemented. so we will implement it now.

func (q *Queue[T]) Front() (*T, error) {
	return &q.data[0], nil
}
Note that this is not the final code, it has a bug which we will find and fix later using tdd

this time test cases are passed.

Let us continue, what happens if we don’t enqueue any element and run the test below

func TestEnqueue(t *testing.T) {
	q := New[int](10)

	if q.Len() != 1 {
		t.Errorf("invalid queue length, expected: %v, got: %v", 1, q.Len())
	}
	front, err := q.Front()
	if err != nil {
		t.Errorf("got error %v while fetching front element", err)
	}
	if *front != 1 {
		t.Errorf("expected: %v, got: %v", 1, *front)
	}
}

the test will crash at q.Front() because q.Front will access q.data[0] which does not exist
crash logs will be similar to those below
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x10f541d]

this is how TDD helps us know bugs in our code, we can check first queue is not empty and only then proceed to access its first element as below

var (
	ErrEmpty = errors.New("queue is empty")
	ErrFull  = errors.New("queue is full")
)

func (q *Queue[T]) Front() (*T, error) {
	if q.IsEmpty() {
		return nil, ErrEmpty
	}
	return &q.data[0], nil
}

Still our Enqueue() code is not complete, it has a bug, if we create a queue with size 1 and enqueue two values into it, it will not complaint (give an error) and simply add

func TestEnqueue(t *testing.T) {
	q := New[int](1)
         q.Enqueue(1)
         q.Enqueue(2)

	if q.Len() != 1 {
		t.Errorf("invalid queue length, expected: %v, got: %v", 1, q.Len())
	}
	front, err := q.Front()
	if err != nil {
		t.Errorf("got error %v while fetching front element", err)
	}
	if *front != 1 {
		t.Errorf("expected: %v, got: %v", 1, *front)
	}
}

this test case will fail at q.Len() != 1 but it should fail at q.Enqueue(2) since queue is full

let us fix this as below

var (
	ErrEmpty = errors.New("queue is empty")
	ErrFull  = errors.New("queue is full")
)

func (q *Queue[T]) IsFull() bool {
	return q.Len() == q.Capacity()
}

func (q *Queue[T]) Enqueue(val T) error {
	if q.IsFull() {
		return ErrFull
	}
	q.data = append(q.data, val)
	return nil
}

Now we can check that when we enqueue 2 it should return “queue is full” error as below

func TestEnqueue(t *testing.T) {
	q := New[int](1)
         q.Enqueue(1)
         err := q.Enqueue(2)
        if err != ErrFull {
		t.Fatalf("expected error: %v, got: %v", ErrFull, err)
	}

	///////
}

Now we can see the power of TDD and how it can help us write bug-free code.

We can separate TestIsFull from the above as below

func TestIsFull(t *testing.T) {
	q := New[int](1)
	q.Enqueue(1)

	if !q.IsFull() {
		t.Errorf("queue should be full but got not full")
	}

	err := q.Enqueue(2)
	if err != ErrFull {
		t.Errorf("expected error: %v, got: %v", ErrFull, err)
	}
}

Let us finish this up by writing the missing Dequeue() func and its test case

func TestDequeue(t *testing.T) {
	q := New[int](10)
	err := q.Dequeue()

	if err != ErrEmpty {
		t.Errorf("expected error: %v, got: %v", ErrEmpty, err)
	}

	q.Enqueue(1)
	err = q.Dequeue()
	if err != nil {
		t.Errorf("expected no error but got: %v", err)
	}

	q.Enqueue(2)
	front, err := q.Front()
	if err != nil {
		t.Errorf("expected no error but got: %v", err)
	}
	if *front != 2 {
		t.Errorf("expected: %v, got: %v", 2, *front)
	}
	q.Dequeue()
	// now queue is again empty
	// so further dequeue should fail
	err = q.Dequeue()
	if err != ErrEmpty {
		t.Errorf("expected error: %v, got: %v", ErrEmpty, err)
	}
}

first testing will fail at the compilation stage, Dequeue is not implemented so let us implement this.

func (q *Queue[T]) Dequeue() error {
	q.data = q.data[1:]
	return nil
}

now if we run about test case it will fail at (crash)

if err != ErrEmpty {
		t.Errorf("expected error: %v, got: %v", ErrEmpty, err)
	}

because there is no element in the queue and we updating q.data = q.data[1:] so q.data[1:] does not exist and it will crash, to fix this we will add IsEmpty() check first

func (q *Queue[T]) Dequeue() error {
	if q.IsEmpty() {
		return ErrEmpty
	}
	q.data = q.data[1:]
	return nil
}

Now our all test cases are passed.

Queue go test .
ok queue 0.364s

Please let me know of any mistakes, improvements, and suggestions through comments

// complete code is here
//queue_test.go
package queue

import "testing"

func TestSize(t *testing.T) {
	q := New[int](10)
	if q.Capacity() != 10 {
		t.Errorf("invalid queue created")
	}
}

func TestIsEmpty(t *testing.T) {
	q := New[int](10)
	if !q.IsEmpty() {
		t.Errorf("invalid queue created")
	}
}

func TestEnqueue(t *testing.T) {
	q := New[int](1)
	q.Enqueue(1)
	err := q.Enqueue(2)

	if err != ErrFull {
		t.Fatalf("expected error: %v, got: %v", ErrFull, err)
	}
	if q.Len() != 1 {
		t.Errorf("invalid queue length, expected: %v, got: %v", 1, q.Len())
	}
	front, err := q.Front()
	if err != nil {
		t.Errorf("got error %v while fetching front element", err)
	}
	if *front != 1 {
		t.Errorf("expected: %v, got: %v", 1, *front)
	}
}

func TestIsFull(t *testing.T) {
	q := New[int](1)
	q.Enqueue(1)

	if !q.IsFull() {
		t.Errorf("queue should be full but got not full")
	}

	err := q.Enqueue(2)
	if err != ErrFull {
		t.Errorf("expected error: %v, got: %v", ErrFull, err)
	}
}

func TestDequeue(t *testing.T) {
	q := New[int](10)
	err := q.Dequeue()

	if err != ErrEmpty {
		t.Errorf("expected error: %v, got: %v", ErrEmpty, err)
	}

	q.Enqueue(1)
	err = q.Dequeue()
	if err != nil {
		t.Errorf("expected no error but got: %v", err)
	}

	q.Enqueue(2)
	front, err := q.Front()
	if err != nil {
		t.Errorf("expected no error but got: %v", err)
	}
	if *front != 2 {
		t.Errorf("expected: %v, got: %v", 2, *front)
	}
	q.Dequeue()
	// now queue is again empty
	// so further dequeue should fail
	err = q.Dequeue()
	if err != ErrEmpty {
		t.Errorf("expected error: %v, got: %v", ErrEmpty, err)
	}
}

Below is the final implementation of Queue in Go.

//queue.go
// This package implements queue in Go

package queue

import "errors"

var (
	ErrEmpty = errors.New("queue is empty")
	ErrFull  = errors.New("queue is full")
)

func New[T any](size int) *Queue[T] {
	return &Queue[T]{data: make([]T, 0, size)}
}

type Queue[T any] struct {
	data []T
}

func (q *Queue[T]) Capacity() int {
	return cap(q.data)
}

func (q *Queue[T]) Len() int {
	return len(q.data)
}

func (q *Queue[T]) IsEmpty() bool {
	return q.Len() == 0
}

func (q *Queue[T]) IsFull() bool {
	return q.Len() == q.Capacity()
}

func (q *Queue[T]) Front() (*T, error) {
	if q.IsEmpty() {
		return nil, ErrEmpty
	}
	return &q.data[0], nil
}

func (q *Queue[T]) Enqueue(val T) error {
	if q.IsFull() {
		return ErrFull
	}
	q.data = append(q.data, val)
	return nil
}

func (q *Queue[T]) Dequeue() error {
	if q.IsEmpty() {
		return ErrEmpty
	}
	q.data = q.data[1:]
	return nil
}
// go.mod

module queue

go 1.19

Comparison of different approaches to implement a queue in Go

Comparison of different approaches to implement a queue in Go
Comparison of different approaches to implement a queue in Go

Major Use Cases of Queue in Go

Queues are a versatile data structure with numerous applications in Go applications. Here are some common use cases:

1. Background Jobs:

  • Processing tasks asynchronously without blocking the main thread. Examples include:
    • Sending emails
    • Resizing images
    • Transcoding videos
    • Generating reports

2. Message Buffers:

  • Acting as a buffer between different systems or components for communication. This helps handle bursts of messages and decouple components. Examples include:
    • Microservices communication
    • Event-driven architectures
    • Message queues for websockets

3. Rate Limiting & Throttling:

  • Controlling the rate at which requests are processed to prevent overloading systems. Examples include:
    • API rate limiting
    • Login attempts throttling
    • Resource access control

4. Data Processing Pipelines:

  • Processing data in stages or steps using a queue as a buffer between stages. Examples include:
    • Log processing pipelines
    • Data analytics pipelines
    • Machine learning training pipelines

5. Task Queues & Workers:

  • Distributing tasks among multiple worker processes using a queue. This helps scale processing and parallel execution. Examples include:
    • Image processing tasks
    • Video encoding tasks
    • Data analysis tasks

6. Event-Driven Architectures:

  • Reacting to events by adding tasks to a queue for processing at their own pace. Examples include:
    • User activity events
    • System events
    • Sensor data processing

7. Caching:

  • Implementing least-recently-used (LRU) caching by storing items in a queue and evicting older ones when the cache reaches capacity.

8. Retry Logic:

  • Implementing retry logic for failed tasks by re-adding them to the queue after a delay or with additional information.

9. Testing & Mocking:

  • Mocking external services by using a queue to control the flow of data and responses.

Benefits of using Queue in Go

Please Find Below the major benefits of using queue in Go

Benefits of using Queue in Go
Benefits of using Queue in Go
  • Order preservation: Tasks are processed in the same order they are added.
  • Decoupling: Components can interact without knowing the details of each other’s implementation.
  • Concurrency: Queue Can be used to parallelize tasks and improve performance.

FAQs on Queue in Go

1. What are queues in Go?

Queues are data structures that follow the “First In, First Out” (FIFO) principle. Elements are added to the back (Enqueue) and removed from the front (Dequeue). They are useful for managing tasks, messages, or data streams in a specific order.

2. What are the different types of queue in Go?

  • Slice-based queue: Simple to implement but less efficient for large datasets.
  • Channel-based queue: Offers blocking/non-blocking operations and concurrency advantages.
  • Third-party libraries: Provide advanced features like priority queues, retry logic, and persistence.

3. How can I implement a simple queue in Go using a slice?

Just go through the implementation provided in this post

4. What are the benefits of using channels for queues in Go?

  • Channels are built-in, efficient, and offer concurrency features.
  • Blocking operations allow waiting for items without busy-waiting.
  • Buffered channels enable handling a limited number of items without blocking.

5. How can I implement a queue using a channel?

queue := make(chan interface{}, 10) // Buffered channel with capacity 10

func Enqueue(item interface{}) {
  queue <- item
}

func Dequeue() interface{} {
  return <-queue
}

6. What are some popular third-party queue libraries in Go?

7. What are some use cases for queues in Go applications?

  • Processing tasks asynchronously (e.g., background jobs, image processing).
  • Handling message buffers for communication between systems.
  • Implementing rate limiting or throttling of requests.
  • Building event-driven architectures with microservices.

8. What are some best practices for using queues in Go?

  • Choose the appropriate queue implementation based on performance needs and concurrency requirements.
  • Consider using buffered channels for better throughput and avoiding blocking operations.
  • Monitor queue size and implement backpressure mechanisms if needed.
  • Handle errors and potential timeouts gracefully.

9. What are some limitations of using queues in Go?

  • Order preservation is crucial, so ensure correct implementation and usage.
  • Large queues can consume memory and require careful monitoring.
  • Deadlocks can occur if not designed correctly for concurrent access.

10. Where can I find more resources and examples on queues in Go?

Conclusion

In conclusion, exploring the implementation of a queue in Go using the Test-Driven Development (TDD) approach offers a structured and systematic method for learning and mastering both the queue data structure and Go’s testing framework. Through this process, developers gain invaluable insights into the intricacies of building a reliable and efficient queue in Go.

The TDD methodology fosters a cycle of writing tests, implementing code, and refining functionalities, thereby ensuring the correctness and robustness of the queue implementation in Go. By adhering to TDD principles, developers reinforce their understanding of the queue’s behavior while simultaneously strengthening their proficiency in Go programming.

This approach not only enhances knowledge of data structures and testing practices but also cultivates a disciplined and methodical mindset essential for software development. Embracing the queue implementation in Go through the TDD approach not only imparts technical expertise but also cultivates a problem-solving mentality vital for building resilient and high-quality software solutions.

Enjoy the post!

Related posts

Spread the love

Leave a Comment

Seraphinite AcceleratorOptimized by Seraphinite Accelerator
Turns on site high speed to be attractive for people and search engines.