Poly - How to limit slice to N dimension of a type

Is there a way to limit the dimensions of a slice to one dimension given as a parameter to a procedure?

I’ve tried all sorts of where clauses in the procedure definition, but can’t seem to lock it down.

slice_1D_proc :: proc(slice: $S/[]$T) {
	fmt.printfln("%v", typeid_of(S))
}

slice_2D := [][]f32{{1.0, 2.0}, {1.0, 2.0}}

slice_1D_proc(slice_2D)

// prints:
// [][]f32

// I'm trying to limit it to only allow []T and not allow [][]T or more dimensions.

As a multidimensional slice can be thought of as a slice of slices, you can test that the type of T is not a slice itself. More information on the where clause can be found here.

import "base:intrinsics"

slice_1D_proc :: proc(slice: $S/[]$T)
	where !intrinsics.type_is_slice(T)
{
	fmt.printfln("%v", typeid_of(S))
}

main :: proc() {
	slice_1D := []f32{1.0, 2.0}
	slice_1D_proc(slice_1D)
	
	slice_2D := [][]f32{{1.0, 2.0}, {1.0, 2.0}}
	slice_1D_proc(slice_2D) // line 21
}

The following messages were presented at compilation:

$ odin run restrict_to_single_dimension_slice.odin -file
/odin_workspace/scratch/restrict_to_single_dimension_slice.odin(8:8) Error: 'where' clause evaluated to false: 
        !intrinsics.type_is_slice(T) 
        where !intrinsics.type_is_slice(T) 
              ^~~~~~~~~~~~~~~~~~~~~~~~~~~^ 
                T :: []f32; 
                S :: [][]f32; 
/odin_workspace/scratch/restrict_to_single_dimension_slice.odin(21:2) at caller location 

There is a lingering question of what you would want the type checking to allow for instead of restrict. Currently you could pass a slice of arrays [][N]T to the procedure and it would compile. This may or may not be desirable behavior. I would recommend looking through the type_is_X procedures in the intrinsics package. Your example is using floats, so something like type_is_numeric(T) might be a more in line with your expectations. Checking for the types you want to support might also be simpler than disallowing the types you do not.

Ah, thanks. That’s a step forward for me. I’ve read through the intrinsics library several times and have used it often, but it did not occur to me to check on T is not a slice. I kept focusing on S.

I’m trying to maintain some type safe printing in a library I’m working on. I need to allow a single dimension slice of any type, but not use “any”. Your suggestion seems to work, only problem is ols does not report miss-use of the procedure at the #caller_location (only at definition location), which might be confusing for 3rd party users. They would not know about an error or bug until compile time. I have an open issue on ols git page that’s getting looked at, but till then, I’m trying to keep things intuitive. Other problem I see now is the error message will say “!intrinsics.type_is_slice(T)” which could also be unintuative to a 3rd party user. They’d be like “hey, I thought this procedure was for a slice?!?”

I’ll have to play around some. I have another procedure to handle 2d slice, maybe I can redirect to it, or combine them, now that my focus is shifted to T and not S.

I do not use LSPs, so I was unaware of OLS’s behaviors. As the where clauses are evaluated at compilation, I’m not entirely surprised that they would reference the line numbers of evaluation/generation instead of the usage line numbers. The usage lines were, however, provided when compiled, as shown in the example output above.

It sounds like you might want to consider making a series of explicitly typed procedures and then wrap them into a procedure group to provide a simpler interface for library users. With the constraints of not wanting to use any, disliking OLS’s output of where clause errors, and wanting very strong type safety with wide coverage, that could be another option for you. Something like:

int_slice_proc    :: proc(slice: []int)    { /*...*/ }
string_slice_proc :: proc(slice: []string) { /*...*/ }
// ...

slice_proc :: proc {
	int_slice_proc,
	string_slice_proc,
	// ...
}

Depending on the complexity of the operations performed within the procedures, you might even be able to generate the code with a script instead of contending with where clauses.

I used a combination of both your suggestions and so far seems to work the way I want. Need to do more testing to be sure.

Limits proc input to only 1 dimension or 2 dimensions. Anything else is flagged at the caller location by ols (so far). A 3 dimension or more is rejected. Also allows for an array/slice of any kind.

It’s a lot of work on the back-end, but significantly reduces syntax and bugs downstream on the front-end (hopefully, still testing).

For reference: This is for printing a single row or multiple rows of data in columns. With a 2D flat screen (:upside_down_face: ), printing column-ized data can only be 2D. In the case of a single row of [N], len(N) is the columns. In the case of multiple rows of [N][M], len(N) is rows and len(M) is columns.

slice_proc :: proc {
	slice_1D_proc,
	slice_slice_2D_proc,
	slice_array_2D_proc,
	slice_dynamic_2D_proc,

	array_1D_proc,
	array_slice_2D_proc,
	array_array_2D_proc,
	array_dynamic_2D_proc,

	dynamic_1D_proc,
	dynamic_slice_2D_proc,
	dynamic_array_2D_proc,
	dynamic_dynamic_2D_proc,
}

slice_1D_proc :: proc(slice: $S/[]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

slice_slice_2D_proc :: proc(slices: $S/[][]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

slice_array_2D_proc :: proc(slices: $S/[][$N]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

slice_dynamic_2D_proc :: proc(slices: $S/[][dynamic]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

array_1D_proc :: proc(slice: $S/[$N]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

array_slice_2D_proc :: proc(slices: $S/[$N][]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

array_array_2D_proc :: proc(slices: $S/[$N][$M]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T && !intrinsics.type_is_dynamic_array(T)) {}

array_dynamic_2D_proc :: proc(slices: $S/[$N][dynamic]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

dynamic_1D_proc :: proc(slice: $S/[dynamic]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

dynamic_slice_2D_proc :: proc(slices: $S/[dynamic][]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

dynamic_array_2D_proc :: proc(slices: $S/[dynamic][$N]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

dynamic_dynamic_2D_proc :: proc(slices: $S/[dynamic][dynamic]$T)
	where !intrinsics.type_is_slice(T) && !intrinsics.type_is_array(T) && !intrinsics.type_is_dynamic_array(T) {}

Welp, I think it’s working. What a brain bender.

I wish that intrinsics had a procedure for dimension count of an array/slice of any type. That would have cut the number of overload procedures from 12 to 6.

slice_1D_proc :: proc(slice: $S/[]$T) where intrinsics.dimension_count(S) == 1 {}
slice_2D_proc :: proc(slice: $S/[]$T) where intrinsics.dimension_count(S) == 2 {}

The commonality between [N]T, []T and [dynamic]T is that they are all sliceable. So I believe you could squash it to just 4 procs.

Something like this:

package slicefmt

import "core:fmt"
import "base:intrinsics"

dimslice_1D :: proc(s: []$T, begin := "[", end := "]")
	where !intrinsics.type_is_sliceable(T) // edit: missed this
{
	fmt.print(begin)
	for t, i in s {
		fmt.printf("%v", t)
		if i != len(s) - 1 do fmt.printf(", ")
	}
	fmt.println(end)
}

dimslice_2D :: proc(s: []$T)
	where intrinsics.type_is_sliceable(T)
{
	for &row in s {
		dimslice_1D(row[:], begin = "|", end = "|")
	}
}

dimslice :: proc(s: $S/[]$T) {
	when intrinsics.type_is_sliceable(T) {
		dimslice_2D(s)
	} else {
		dimslice_1D(s)
	}
}

sliceable :: proc(s: $S)
	where intrinsics.type_is_sliceable(S)
{
	s := s
	sx := s[:]
	dimslice(sx)
}

main :: proc() {
	xs : [][]int = {{1,2,3}, {4,5,6}, {7,8,9}}
	ys : [3][3]int = {{1,2,3}, {4,5,6}, {7,8,9}}
	zs : [dynamic][dynamic]int = make([dynamic][dynamic]int)
	for i in 0..<3 {
		z := make([dynamic]int)
		append(&z, i+1, i+2, i+3)
		append(&zs, z)
	}
	qs : [][2]int = {{1,2}, {3,4}, {5,6}}

	fmt.println("XS:")
	sliceable(xs)
	fmt.println("YS:")
	sliceable(ys)
	fmt.println("ZS:")
	sliceable(zs)
	fmt.println("QS:")
	sliceable(qs)

	// edit: prove it won't go above 2D, uncomment results in err
	// ws : [][2][dynamic]int = {{make([dynamic]int), make([dynamic]int)}}
	// sliceable(ws)
}

Will produce this:

XS:
|1, 2, 3|
|4, 5, 6|
|7, 8, 9|
YS:
|1, 2, 3|
|4, 5, 6|
|7, 8, 9|
ZS:
|1, 2, 3|
|2, 3, 4|
|3, 4, 5|
QS:
|1, 2|
|3, 4|
|5, 6|

2 Likes

That works quite well. Thank you very much for sharing.

Now it’s a matter of design choice. In your example greater than 3D is flagged at the definition site by ols and not the caller location where-as my over-engineered 12 procs are so tightly defined, anything above 2D is flagged at the caller location. Hmm, I’ll be playing with these ideas some…

Oh well, it’s still possible just a bit ugly.

sliceable :: proc(s: $S)
	where intrinsics.type_is_sliceable(S),
	      (intrinsics.type_is_sliceable(intrinsics.type_elem_type(S)) &&
	      !intrinsics.type_is_sliceable(intrinsics.type_elem_type(intrinsics.type_elem_type(S))))
{
	dimslice :: proc(s: []$T, begin := "[", end := "]") {
		fmt.print(begin)
		for t, i in s {
			fmt.printf("%v", t)
			if i != len(s) - 1 do fmt.printf(", ")
		}
		fmt.println(end)
	}

	s := s
	sx := s[:]

	ELEM2D :: intrinsics.type_elem_type(S)
	when intrinsics.type_is_sliceable(ELEM2D) {
		for &row in sx {
			dimslice(row[:], begin = "|", end = "|")
		}
	} else {
		dimslice(sx)
	}
}

Then the callsite is named on the “at caller location” line:

$ odin build .
/tmp/scratch/main.odin(8:8) Error: 'where' clause evaluated to false: 
        (intrinsics.type_is_sliceable(intrinsics.type_elem_type(S)) && !intrinsics.type_is_sliceable(intrinsics.type_elem_type(intrinsics.type_elem_type(S)))) 
        (intrinsics.type_is_sliceable(intrinsics.type_elem_type(S)) && ... 
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ... 
                S :: [][2][dynamic]int; 
/tmp/scratch/main.odin(55:2) at caller location 

Yeah, makes sense to me when I see it. I’m on the fence about sharing a library that may not flag at caller location while coding and only fail during compilation. I suppose trust in the 3rd-pary is needed.

I did get a response from BradLewis (hope he doesn’t mind me quoting him here)

I agree this is not desired. Currently the odin checker reports that error at the definition of the function which is why it appears there. It does also tell us the call site but only in the error message that you see, not as structured json.
I’ll take a look and see if I can improve this.