JavaRush /Courses /Go SELF /Methods on “slice types”: type Items [] Item

Methods on “slice types”: type Items [] Item

Go SELF
Level 22, Lesson 4
Available

1. Introduction

When you’ve only just learned slices, it seems like it’s enough to use []Item everywhere, and perform operations as regular functions: AddItem(items, x), MarkDone(items, id) and so on. You can do that, and in the very beginning it’s fine. But you quickly start to feel the “mess”: functions grow, parameters get confusing, and reading the code starts to look like a microwave instruction manual with half the pages missing.

In Go there’s a simple technique: make a named type on top of a slice — type Items []Item. Then you can declare methods and get a readable API: items.Add(...), items.MarkDone(...), items.FindByID(...). This isn’t an “OOP magical transformation”, just a convenient way to organize code: operations on the collection live nearby and are called consistently.

It’s important to remember one key fact: you can’t declare methods on the “bare” type []Item, because it’s an unnamed type. Methods can only be declared on named types defined in your package. In the Go blog post about type parameters there’s a great example featuring a named slice type MySlice []string and a method String() string, showing the core idea of a “slice type with methods”.

Model: Item and the Items collection

To keep the examples coherent, imagine we’re continuing a mini “task list” (todo) application. Previously we had Item (a single task), and we could print it nicely via String(). Now we want a “smart task list” — not just []Item, but a type with methods.

First, let’s declare the model. Nothing supernatural here: fields, zero values, everything you’ve already seen. We’ll keep the model simple: ID, Title, Done.

package main

type Item struct {
	ID    int
	Title string
	Done  bool
}

type Items []Item

From this point on, Items is a separate type. Internally it’s still a slice, but externally we can expose neat methods instead of a scatter of utilities across the project.

2. What gets copied with receiver Items

Next comes a bit of “slice physics”, without which it’s easy to step on rakes. A slice is not a “built-in list of objects”, but a view over an array: internally it stores a pointer to the data, a length, and a capacity.

So when you write a method with a value receiver like this:

func (it Items) Len() int { return len(it) }

it’s not the “entire task list” that gets copied, but only the “slice header” (pointer + len + cap). The data (elements) usually remain shared. From this follow two different kinds of changes:

  1. changing elements (for example, it[i].Done = true) is often “visible from the outside”, because the data are shared;
  2. changing the length via append is a different game: it’s about the header: append(it, x) may return a new header, and you must either return it or write it through a pointer.

You can picture it like this:

flowchart LR
    A["Items (receiver)"] --> H["header: ptr + len + cap"]
    H --> D["underlying array (data)"]
    B["Items in main"] --> H2["header: ptr + len + cap"]
    H2 --> D

Two different “headers” can point to the same data. That’s exactly why some operations “seem to mutate everything by themselves”, and some don’t.

4. Read-only methods on a collection

When we say “methods on a collection”, many people automatically think about Add and Delete. But it’s better to start with read-only methods: they almost always work on a value receiver and don’t create controversial situations.

Len() — a small thing that improves readability

Calling len(items) isn’t hard. But items.Len() sometimes reads nicer, especially when the collection becomes part of the domain model and you want to “hide” details. It’s not a mandatory style, but it’s a good teaching example.

package main

func (it Items) Len() int {
	return len(it)
}

FindByID() — a typical search in a collection

Task lists almost always search an element by ID. Let’s make a method that returns (Item, bool): “found / not found”. We’re not complicating it with errors yet — that’s fine for a simple model layer.

package main

func (it Items) FindByID(id int) (Item, bool) {
	for _, x := range it {
		if x.ID == id {
			return x, true
		}
	}
	return Item{}, false
}

What’s nice here is that the call reads almost like an “English sentence”:

item, ok := items.FindByID(10)

5. Mutating elements: why for _, v := range “betrays” you

Now we’re approaching a classic trap: you want to iterate over Items and change fields of elements. The logic in your head is simple: “I’m iterating over the list elements, so I’m changing the list elements”. But range over a slice gives you a copy of the element in the variable v. You’ve seen this before, but here it’s especially painful: the code looks correct and even compiles… it just doesn’t change anything.

Let’s show the wrong version (and yes, it really won’t do anything):

package main

func (it Items) MarkAllDoneWrong() {
	for _, x := range it {
		x.Done = true // modifying a copy, not the element in the slice
	}
}

The correct version is to mutate by index. The index gives access to the real element in the backing array:

package main

func (it Items) MarkAllDone() {
	for i := range it {
		it[i].Done = true
	}
}

At this point it’s useful to remember a short rule: if you want to modify slice elements, most of the time you write for i := range s, not for _, v := range s. This isn’t a “ban”, just a practical reflex that saves hours of debugging and a couple of nerve cells.

6. Changing length: append and choosing an API style

Here’s where it gets interesting: you can change elements “as if directly”, but you can’t change the length the same way. The reason is that append returns a new slice: sometimes it stays on the same array, sometimes it moves to a new one (if capacity runs out). So you have to use the result of append.

In the official discussion of how functions behave on slices, it’s emphasized that if an operation changes the length, it must return the new slice to the caller — for the same reason append returns a value.

From here you have two reasonable designs. Both are “Go-like”; just pick one and don’t mix them without a good reason.

Value style: the method returns a new Items

This style is similar to “functional”: you don’t try to change the slice variable inside the method; you honestly say “here is the new version of the collection”.

package main

func (it Items) Add(x Item) Items {
	return append(it, x)
}

Usage:

items = items.Add(Item{ID: 1, Title: "Learn Go"})

If you forget the assignment, you’ll get the famous: “why didn’t it add?”. And it’s not mysticism — you ignored the returned header.

In the article about robust functions over slices, it’s shown directly that ignoring the results of operations that return a new slice (including append-like ones and slices.Delete) is a mistake: the slice length in the old variable will stay the same, even if the underlying data have already been shifted.

Pointer style: the method mutates *Items

This style appeals to those who want to write items.Add(...) without items = .... Then the receiver is a pointer, and inside we must write the result of append back into *it.

package main

func (it *Items) Add(x Item) {
	*it = append(*it, x)
}

Usage:

items.Add(Item{ID: 2, Title: "Write tests"})

This design is especially convenient when you have many methods that change length (Add, Delete, Insert), and you want them all to work “in one direction”: call the method — the collection changes.

7. Cheat sheet and mini example: a convenient API for todo

When students get confused, they usually don’t get confused about syntax, but about the nature of changes. A slice is simultaneously similar to a “value” and a “reference” — hence the cognitive dissonance. Below is a very practical table that helps keep your head straight.

What we do in a method Example Visible from the outside with receiver Items What people usually choose
Change element fields
it[i].Done = true
Yes (data are shared) value receiver Items
Swap elements
it[i], it[j] = it[j], it[i]
Yes value receiver Items
Change length (append, deletion)
it = append(it, x)
No, if you don’t return/write it back either Items + return, or *Items

And one more useful note: a named slice type is not only about methods. It also helps you not to lose those methods while working with code. In the MySlice story from the Go blog, the problem was that a function returned []string, not MySlice, and the method String() “disappeared”. It’s a good mental anchor: the named type lives as long as you don’t turn it back into a “naked” []T.

Now let’s assemble a small snippet that shows what all this looks like in main. We’re not building a full app or going into storage or a CLI — what matters is the effect of “the API became human-friendly”.

Let’s make two methods: Add (pointer style) and MarkDone (mutating an element by index).

package main

func (it *Items) Add(x Item) {
	*it = append(*it, x)
}

func (it Items) MarkDone(id int) bool {
	for i := range it {
		if it[i].ID == id {
			it[i].Done = true
			return true
		}
	}
	return false
}

And a small main that uses it:

package main

import "fmt"

func main() {
	items := Items{}

	items.Add(Item{ID: 1, Title: "Learn Go"})
	items.Add(Item{ID: 2, Title: "Drink water"})

	ok := items.MarkDone(2)
	fmt.Println("marked:", ok)       // marked: true
	fmt.Println("len:", items.Len()) // len: 2
	fmt.Println(items[1].Done)       // true
}

Yes, here we still read items[1] directly. That’s fine: Items is a slice, indexing works. But in a good design, you usually gradually “raise the abstraction level”: you expose methods outward (for example, items.DoneIDs() or items.ListUndone()), and you use direct indexing only where it’s truly needed.

8. Common mistakes

Mistake №1: trying to declare a method on []Item.
Often it looks like this: a student writes func (s []Item) Add(...) and wonders why the compiler complains. The reason is simple: []Item is an unnamed type; you can’t attach methods to it. It’s fixed with exactly one step: introduce a named type type Items []Item and work through it.

Mistake №2: “Adding doesn’t work” because you ignored the result of append.
In value style you write items.Add(x), but you don’t assign it back, and the length doesn’t change. Your brain says “but I added it!”, and Go replies “I returned you a new slice, and you didn’t take it”. This is exactly the class of mistakes warned about with slices.Delete examples: you changed the data, but didn’t update the header variable, and the slice kept its old length.

Mistake №3: trying to change elements via for _, v := range.
The code compiles, looks logical, but the changes don’t persist. Because v is a copy of the element. For element mutations, use the index: for i := range it { it[i]... }. This mistake is especially tricky because it doesn’t panic and doesn’t give hints — it’s just “nothing happens”.

Mistake №4: mixing API styles for no reason.
For example, Add is done in pointer style (mutates), Delete is in value style (returns a new slice), and a couple more methods return bool, but at the same time “sometimes mutate, sometimes don’t”. Users of your type start guessing whether they need to write items = items.Delete(...) or “it will change by itself”. It’s better to pick one style for methods that change length and stick to it.

Mistake №5: losing methods by “sliding back” to []Item.
Sometimes you write a function somewhere that takes []Item and returns []Item, and suddenly your nice Items loses methods because you returned a “naked” slice. It’s the same effect shown in the MySlice story: the type turned into []T, and the method String() (or any other) is no longer available.

1
Task
Go SELF, level 22, lesson 4
Locked
Inventory length
Inventory length
1
Task
Go SELF, level 22, lesson 4
Locked
Pass lookup
Pass lookup
1
Task
Go SELF, level 22, lesson 4
Locked
Mark as done
Mark as done
1
Task
Go SELF, level 22, lesson 4
Locked
Editor commands
Editor commands
1
Survey/quiz
Methods and method sets, 22 level, 4 lesson
Unavailable
Methods and method sets
Methods and method sets
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION