slog: add Group Values

Introduce a kind of Value that is a tagged group of other Values,
represented by a []Attr.

Now a type can provide a LogValue method that returns multiple Attrs,
so it can represent a loggable version of itself to slog.Handlers in
a general way.

This CL just adds the implementation to Value.
Subsequent CLs will provide Handler implementations and tests.

Change-Id: Ib0204daf146f545bd20abe4e58664899c8ef45db
Reviewed-on: https://go-review.googlesource.com/c/exp/+/437959
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/slog/attr.go b/slog/attr.go
index 32d862d..90541ee 100644
--- a/slog/attr.go
+++ b/slog/attr.go
@@ -57,6 +57,17 @@
 	return Attr{key, DurationValue(v)}
 }
 
+// Group returns an Attr for a Group Value.
+// The caller must not subsequently mutate the
+// argument slice.
+//
+// Use Group to collect several Attrs under a single
+// key on a log line, or as the result of LogValue
+// in order to log a single value as multiple Attrs.
+func Group(key string, as ...Attr) Attr {
+	return Attr{key, GroupValue(as...)}
+}
+
 // Any returns an Attr for the supplied value.
 // See [Value.AnyValue] for how values are treated.
 func Any(key string, value any) Attr {
diff --git a/slog/example_marshaler_test.go b/slog/example_marshaler_test.go
new file mode 100644
index 0000000..1b07f82
--- /dev/null
+++ b/slog/example_marshaler_test.go
@@ -0,0 +1,34 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package slog_test
+
+import (
+	"golang.org/x/exp/slog"
+)
+
+type Name struct {
+	First, Last string
+}
+
+func (n Name) MarshalLog() slog.Value {
+	return slog.GroupValue(
+		slog.String("first", n.First),
+		slog.String("last", n.Last))
+}
+
+func ExampleMarshaler() {
+	n := Name{"Perry", "Platypus"}
+	slog.Info("mission accomplished", "agent", n)
+
+	// JSON Output would look in part like:
+	// {
+	//     ...
+	//     "msg": "mission accomplished",
+	//     "agent": {
+	//         "first": "Perry",
+	//         "last": "Platypus"
+	//     }
+	// }
+}
diff --git a/slog/example_test.go b/slog/example_test.go
new file mode 100644
index 0000000..5681ab2
--- /dev/null
+++ b/slog/example_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package slog_test
+
+import (
+	"net/http"
+	"time"
+
+	"golang.org/x/exp/slog"
+)
+
+func ExampleGroup() {
+	var r *http.Request
+	start := time.Now()
+	// ...
+
+	slog.Info("finished",
+		slog.Group("req",
+			slog.String("method", r.Method),
+			slog.String("url", r.URL.String())),
+		slog.Int("status", http.StatusOK),
+		slog.Duration("duration", time.Since(start)))
+}
diff --git a/slog/value.go b/slog/value.go
index 120e7cf..bf6d930 100644
--- a/slog/value.go
+++ b/slog/value.go
@@ -9,6 +9,8 @@
 	"math"
 	"strconv"
 	"time"
+
+	"golang.org/x/exp/slices"
 )
 
 // Definitions for Value.
@@ -29,6 +31,7 @@
 	StringKind
 	TimeKind
 	Uint64Kind
+	GroupKind
 	LogValuerKind
 )
 
@@ -41,6 +44,7 @@
 	"String",
 	"Time",
 	"Uint64",
+	"GroupKind",
 	"LogValuer",
 }
 
@@ -93,6 +97,12 @@
 	return Value{num: uint64(v.Nanoseconds()), any: DurationKind}
 }
 
+// GroupValue returns a new Value for a list of Attrs.
+// The caller must not subsequently mutate the argument slice.
+func GroupValue(as ...Attr) Value {
+	return groupValue(as)
+}
+
 // AnyValue returns a Value for the supplied value.
 //
 // Given a value of one of Go's predeclared string, bool, or
@@ -139,6 +149,8 @@
 		return Float64Value(v)
 	case float32:
 		return Float64Value(float64(v))
+	case []Attr:
+		return GroupValue(v...)
 	case Kind:
 		panic("cannot store a slog.Kind in a slog.Value")
 	case *time.Location:
@@ -153,7 +165,7 @@
 // Any returns the Value's value as an any.
 func (v Value) Any() any {
 	switch v.Kind() {
-	case AnyKind, LogValuerKind:
+	case AnyKind, GroupKind, LogValuerKind:
 		return v.any
 	case Int64Kind:
 		return int64(v.num)
@@ -252,6 +264,12 @@
 	return v.any.(LogValuer)
 }
 
+// Group returns the Value's value as a []Attr.
+// It panics if the Value's Kind is not GroupKind.
+func (v Value) Group() []Attr {
+	return v.group()
+}
+
 //////////////// Other
 
 // Equal reports whether two Values have equal keys and values.
@@ -272,6 +290,8 @@
 		return v1.time().Equal(v2.time())
 	case AnyKind, LogValuerKind:
 		return v1.any == v2.any // may panic if non-comparable
+	case GroupKind:
+		return slices.EqualFunc(v1.uncheckedGroup(), v2.uncheckedGroup(), Attr.Equal)
 	default:
 		panic(fmt.Sprintf("bad kind: %s", k1))
 	}
@@ -295,7 +315,7 @@
 		return append(dst, v.duration().String()...)
 	case TimeKind:
 		return append(dst, v.time().String()...)
-	case AnyKind, LogValuerKind:
+	case AnyKind, GroupKind, LogValuerKind:
 		return append(dst, fmt.Sprint(v.any)...)
 	default:
 		panic(fmt.Sprintf("bad kind: %s", v.Kind()))
diff --git a/slog/value_safe.go b/slog/value_safe.go
index f6dc422..532d4de 100644
--- a/slog/value_safe.go
+++ b/slog/value_safe.go
@@ -35,6 +35,8 @@
 		return k
 	case *time.Location:
 		return TimeKind
+	case []Attr:
+		return GroupKind
 	case LogValuer:
 		return LogValuerKind
 	default:
@@ -61,3 +63,13 @@
 	var buf []byte
 	return string(v.append(buf))
 }
+
+func groupValue(as []Attr) Value {
+	return Value{any: as}
+}
+
+func (v Value) group() []Attr {
+	return v.any.([]Attr)
+}
+
+func (v Value) uncheckedGroup() []Attr { return v.group() }
diff --git a/slog/value_test.go b/slog/value_test.go
index 71e4d8c..8e27bda 100644
--- a/slog/value_test.go
+++ b/slog/value_test.go
@@ -23,6 +23,7 @@
 		BoolValue(false),
 		AnyValue(&x),
 		AnyValue(&y),
+		GroupValue(Bool("b", true), Int("i", 3)),
 	}
 	for i, v1 := range vals {
 		for j, v2 := range vals {
diff --git a/slog/value_unsafe.go b/slog/value_unsafe.go
index 5a4ad6c..8fe69d3 100644
--- a/slog/value_unsafe.go
+++ b/slog/value_unsafe.go
@@ -33,8 +33,10 @@
 	any any
 }
 
-// stringptr is used in field `a` when the Value is a string.
-type stringptr unsafe.Pointer
+type (
+	stringptr unsafe.Pointer // used in Value.any when the Value is a string
+	groupptr  unsafe.Pointer // used in Value.any when the Value is a []Attr
+)
 
 // Kind returns the Value's Kind.
 func (v Value) Kind() Kind {
@@ -45,6 +47,8 @@
 		return StringKind
 	case *time.Location:
 		return TimeKind
+	case groupptr:
+		return GroupKind
 	case LogValuer:
 		return LogValuerKind
 	default:
@@ -81,3 +85,21 @@
 	var buf []byte
 	return string(v.append(buf))
 }
+
+func groupValue(as []Attr) Value {
+	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&as))
+	return Value{num: uint64(hdr.Len), any: groupptr(hdr.Data)}
+}
+
+// Group returns the Value's value as a []Attr.
+// It panics if the Value's Kind is not GroupKind.
+func (v Value) group() []Attr {
+	if sp, ok := v.any.(groupptr); ok {
+		return unsafe.Slice((*Attr)(sp), v.num)
+	}
+	panic("Group: bad kind")
+}
+
+func (v Value) uncheckedGroup() []Attr {
+	return unsafe.Slice((*Attr)(v.any.(groupptr)), v.num)
+}