"Pythonic" way to differentiate between a string and a list of strings

This seems to be a relatively common issue in Python: you want to have a nice flexible API which can either accept a single argument, or multiple arguments in the form of a list. But, since strings are iterable (and iterating through them returns strings), the Pythonic Ask for Forgiveness Rather than Permission style doesn't seem to immediately work, since trying to iterate through your argument will work without error, whether you're being passed a collection or just a single string.

The best solution I know of is to just type check your arguments with a isinstance(foo, basestring) (as described in this stack overflow question). However, this feels very un-Pythonic in both its asking for permission rather than foregiveness, and in its dependency on type hierarchies rather than duck typing. Does anyone have a different (better) way of differentiating between an input list of strings, and just a regular string? Or is this solution just an unavoidable compromise?

Posted Mar 27, 2018, 6:10 PM UTC

418
ImportBracesfrom __future__ import braces•

you could only accept lists, even if it is no or even one argument. Makes everything easier.

7
Blue_VisionOP•

I'd consider it generally poor practice for a function to accept a list but not a tuple. Of course, you could add tuples to your check, but that starts feeling very finnickey, and then do we also want to allow sets? Do we allow types which inherit from a list? At the very least, it doesn't strike me as a generally applicable rule of thumb.

2
dig-up-stupid•

They're talking about the API design, not the type checking or Python code. A function that expects either a single string or a list of - arbitrary? - other arguments is a red flag for a poorly designed function in the first place. So instead of having "a nice flexible API which can either accept a single argument, or multiple arguments in the form of a list", you instead have "an even nicer API which expects its arguments in exactly one standardized form".

Would it still behave weirdly if you pass it a string, which is an iterable? Maybe, but there are presumably limitless inputs that would cause the function to fail, and the only thing special about strings in this regard is that they are common. "Ask for Forgiveness Rather than Permission" goes both ways. If someone gives your function an input that will make it explode, you should have a concrete reason (such as actually being able to fix the problem) before you do anything besides let it.

7
tunisia3507•

I disagree. This is something I've come across several times when writing recursive functions with nested lists of strings.

2
dig-up-stupid•

When you are doing that (eg flattening arbitrarily nested lists, or whatever) your base case is already a type check by definition. So strings in the lists can interfere with the function, but the problem is solved by adding strings (and whatever else might come along, ie bytes) to the type check that's already there anyway. That might not seem elegant (though I wouldn't call it inelegant, either) but I doubt it's what motivated OP's question and I don't see how it's particularly relevant. What am I missing?

2
ramse•

Why not convert the string into a list

if isinstance(value, str):
    value = [value]

for item in value:
    ...
5
p10_user•

I think this is a simple, safe solution.

1
lanemikmikelane•

This is one issue that dynamically typed languages have. You can't know for sure what was passed to your function unless you ask it specifically. It is a very common idiom to do something like this:

def foo(a):
    assert isinstance(a, int)
    # rest of your function code here
    pass

This function, of course, is illustrating a scenario where the input is required to be an int and the assertion is enforcing this precondition. So your options are several in my opinion. One, you could do it the way you've coded it. If it is an instance of a string, do one thing, if it is a list, do another (though, I suggest you should do something else if it is neither). Two, you could force everything passed to be a list and that no single strings are being sent using an assert like above. Three, you could have a parameter that informs your function.

def my_func(type_of_thing, thing):
    if type_of_thing == 'string':
        # do a string thing
        pass
    elif type_of_thing == 'list':
        # do a list thing
        pass

Of course, this is just the same as option one, but you're putting the responsibility in the hands of the programmer (which is probably not the best option). Fourth, you could have multiple functions, one tailored to work with strings, one for lists of strings (which isn't really much different than any of the above options and once again relies on the programmer).

There are probably other things you can do, too. There isn't a one size fits all approach here, unfortunately. There is a give and take with any decision, and with dynamic typing you get high level code that is easy to write and easy to understand, but you also sometimes have to use ugly code to deal with scenarios where type matters.

4
Blue_VisionOP•

but you also sometimes have to use ugly code to deal with scenarios where type matters.

I was worried this might unfortunately be the answer. Usually I don't like to use option one/four since, as you mentioned, dynamic/duck typing has the strength of allowing APIs to be super flexible. But the string/list-of-things differentiation may not be the hill to die on in duck typing purity.

As you said, it seems a general issue with dynamically typed languages (and only Python's rather extreme flexibility in particular) so I was wondering if there was some sort of general dynamic/duck typing solution which could be applied. Hopefully this isn't the beginning of the road that takes programmers relapsing into static JVM languages :'(

1
LifeIsBio•

What about:

def func(*args):
    for arg in args:
        print(arg)
    

That way, you have a lot of built in flexibility about how you call func:

func('a')
func('a', 'b')
str_ls = ['a', 'b']
func(*str_ls)
2
lanemikmikelane•

Of course, if self-documenting code is a thing you value, the *args, **kwargs idiom is a very bad idea, indeed.

1
LifeIsBio•

If you're using type annotations, you can do:

def func(*args : str):

which helps.

1
[deleted]•

Do you have a concrete example?

In general it would be bad design to accept/expect a string or a list of strings. If the user of your code wants to only provide one string, it should be contained in a list. This makes everything consistent and you do not have to worry about types at all.

2
Blue_VisionOP•

The example which prompted my musing is a small application I am building for internal use which tidies and aggregates data output by another internal application which runs hardware shmoos (1D or 2D searches through parameter space such as frequency and voltage to find conditions under which the hardware works properly/doesn't work properly). The data can be imagined as 1D or 2D boolean arrays from which we want to extract ranges of continuous passing tests.

The data needs to be aggregated at multiple levels for summary; e.g. baseline chip voltage, frequency, the chip in use itself, but for 99% of situations we only want to aggregate on a specific test axis. So, we'd say "get me the overlap of all the ranges of shmoos which test inbound memory clock voltage variation at a base frequency of 1600MHz". My design is to basically express this as get_overlaps("IBDQS", frequency=1600). However, there's a small 1% corner case where we actually want to get the overlap on multiple axes; e.g. "get me the overlap of all the ranges of shmoos which test clock1 voltage, clock2 voltage, clock3 voltage, bus1 voltage, or memory rail 1 voltage at 1600MHz". That is, get_overlaps(["CLK1", "CLK2", "CLK3", "Offset1", "MEMV1"], frequency=1600).

My instinct is to keep things simple and not let the corner case dictate the general interface; I don't want to make my method get_overlaps(axis_list, frequency=None, voltage=None, ...) when 99% of the time the application will just call get_overlaps([axis]). In addition, the method is functionally doing the same thing in both the 99% use case and the 1% use case; taking a bunch of criteria to filter the test collection on, and apply the same aggregating algorithm to that list. So it does not make sense to create a duplicate method which is

get_overlaps_from_collection(axis_list, **kwargs): 
    aggregate([get_overlaps(axis, **kwargs) for axis in axis_list])

and give the poor hardware engineers who will invariably need to extend my code an extra few hours of planning and testing to make sure they're correctly handling the corner case by making sure they're calling the right method corresponding to the right type of input.

Statically checked languages with single dispatch make this really easy to solve: we have two methods that functionally do the same thing with very small differences in how the inputs are specified, so we just extend the function to handle e.g. strings and collections of strings as inputs, and the compiler decides which to use instead of the programmer. Some may call that encouraging laziness, I call it providing a clear interface that worries about what the code's tangibly doing rather than the implementation details.

1
[deleted]•

Okay.

get_overlaps(["IBDQS"], frequency=1600)

It would be a very easy fix though. Alternatively you could do:

def get_overlaps(*axises, frequency=None, voltage=None...):
    pass

Then your edge case would have to be responsible to unpack its list.

1
baghiq•

Not sure if it's helpful or not. In one of my project, where type of the input matters, I use singledispatch model to differential types to do different function calls. It worked quite well.

https://docs.python.org/3/library/functools.html#functools.singledispatch

Also, in your case, if you want to truly follow Pythonic model, then you shouldn't even bother checking. List.append exhibits this behavior:

list_a = [1, 2 ,3]
list_b = [1, 2, 3]
list_a.extend(['abc'])
list_b.extend('abc')
print(list_a)
print(list_b)

Output:

[1, 2, 3, 'abc']
[1, 2, 3, 'a', 'b', 'c']
1