Skip to content

proposal: x/exp/slices: Find function to select first element matching a condition #52006

@tmaxmax

Description

@tmaxmax

When we want to retrieve an element that matches a given condition from a slice, with the current design of the slices package we use IndexFunc:

i := slices.IndexFunc(slice, func (value T) bool {
  // a condition
})
if i == -1 {
  return
}

elem := slice[i] // or use it as slice[i]

The issue with this solution is that many times we do not need the index of the value. In those cases, we either end up with a variable declaration and an otherwise unused index variable that both hamper readability, or we name the returned index suggestively, which results in lengthy names and clumsy access to the element. For example:

var input []string

indexInputWithFooPrefix := slices.IndexFunc(input, func (value string) bool {
  return strings.HasPrefix(value, "foo")
}
if indexInputWithFooPrefix == -1 {
  // not found
}

processInput(input[indexInputWithFooPrefix])

Both patterns can get really ugly when having to find elements that match other conditions, too. Either reuse an index variable, or name all of them accordingly. Besides, the i == -1 (or i >= 0, if preferred) check adds unnecessary cognitive load.

To solve these readability and usability concerns, I propose adding a Find function to the slices package, with the following declaration:

func Find[E any](s []E, f func(T) bool) (T, bool)

The examples above become much more easier to reason about:

var input []string

inputWithFooPrefix, found := slices.Find(input, func (value string) bool {
  return strings.HasPrefix(value, "foo")
}
if !found {
  // not found
}

// do whatever

The found variable can safely be reused, as demonstrated by our usage of the "comma, ok" idiom. It could also be named ok - this way, Find would fit together with map lookups and type assertions, which would make the language more uniform.

Here are some examples from open-source repositories showing how Find could simplify code:

Before:

func (r *router) findRoute(name string) *route {
	for _, route := range r.getRoutes() {
		if route.name == name {
			return route
		}
	}

	return nil
}

After:

func (r *router) findRoute(name string) *route {
	route, _ := slices.Find(r.getRoutes(), func (rt *route) bool {
		return rt.name == name
	})
	return route
}

Before:

func GetDebuggerPath() string {
	// --snipped--
	for _, debugger := range list {
		if strings.Contains(debugger.Url, "spotify") {
			return debugger.WebSocketDebuggerUrl
		}
	}

	return ""
}

After:

func GetDebuggerPath() string {
	// --snipped--
	debugger, _ := slices.Find(list, func (d debugger) bool {
		return strings.Contains(d.Url, "spotify")
	})
	return debugger.WebSocketDebuggerUrl
}

Before:

func dbGetArticle(id string) (*Article, error) {
	for _, a := range articles {
		if a.ID == id {
			return a, nil
		}
	}
	return nil, errors.New("article not found.")
}

After:

func dbGetArticle(id string) (*Article, error) {
	article, found := slices.Find(articles, func (a *Article) bool {
		return a.ID == id
	})
	if !found {
		return nil, errors.New("article not found.")
	}
	return article, nil
}

In this case, I would have written the function like this:

func dbGetArticle(id string) (*Article, bool) {
	return slices.Find(articles, func (a *article) bool {
		return a.ID == id
	})
}

and create the error message at the call site. This is actually very similar to the use case that made me write this proposal: in our codebase we have a Store interface with many FindBy* methods, which return the type and a boolean indicated whether the value was found or not.

Before:

func (c *Cron) Entry(id EntryID) Entry {
	for _, entry := range c.Entries() {
		if id == entry.ID {
			return entry
		}
	}
	return Entry{}
}

After:

func (c *Cron) Entry(id EntryID) Entry {
	entry, _ := slices.Find(c.Entries(), func (e Entry) bool {
		return e.ID == id
	})
	return entry
}

We see that in most cases the zero value is actually helpful, which results in the found check being completely elided, in the same way we don't always check the ok value when using maps. IndexFunc does not give us this benefit - indexing with -1 is an obvious panic, so we always have to check the index. Having the Find function could even potentially eliminate all these helper methods shown here - using Find directly in code is pleasant and concise.

Adding this function would bring us in line with languages like Scala, Rust and Javascript, too.

Note: This is not similar to #50340, as that proposal modifies the existing BinarySearch functions.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions