I only have a few points to add to Dannnno's, Sriv's, and Martin Thoma's excellent answers.
Consistency
Sometimes, you abbreviate string as "str", sometimes as "s", sometimes not at all. Sometimes, you put the type in the identifier, sometimes not. When you put the type in the identifier, you sometimes put it as a prefix, sometimes as a suffix.
You should choose one style and stick with it. If you are editing some existing code, you should adapt your style to be the same as the existing code. If you are part of a team, you should adapt your style to match the rest of the team.
In general, if you use two different ways to write the exact same thing, the reader will think that you want to convey a message with that. So, you should only use two different ways of writing the same thing IFF you actually want to convey some extra information.
Vertical whitespace
Your code is all bunched up together. Some empty lines would allow the code room to breathe, for example in the integer_to_reverse_binary
function. The function is clearly separated into a series of steps: setup, compute, return. Some whitespace would help draw attention to those steps:
def integer_to_reverse_binary(int_value):
'''returns a string, not a numerical'''
out_s = ''
if int_value == 0:
#to avoid ValueError on the log2 call
return '0'
#find length of bin string
bin_digits = floor(log2(int_value)) + 1
for i in range(bin_digits):
out_s = out_s + str(int_value // 2**i % 2)
return out_s
Naming
Let's look at these names:
int_value
rev_str
forward_str
out_s
s
rev_str
, forward_str
, s
, and out_s
all contain strings. And yet, two of them are named str
and two of them are named s
. forward_str
and rev_str
are right next to each other in the code, they conceptually do the same thing (naming a temporary string), yet the word forward
is spelled out, the word rev
erse is abbreviated.
Some of the names are very cryptic (e.g. s
) and some very detailed (e.g. integer_to_reverse_binary
).
Names are important. Names are also hard.
A good name should be intention-revealing. They should convey meaning.
int_value
, for example, does not convey much meaning. You can only pass values as arguments, so every argument is always a value. Thus, the word "value" doesn't tell me anything I don't already know. And int
only tells me what it is, but not what it does and what it's for, how it is used, why it is there.
Compared to int_value
, even just n
would be a better name, since it does actually tell the reader something: "this is just a number, and there's nothing special about it, so it doesn't deserve a name".
It is also not quite correct. In the question, you write [bold emphasis mine]:
This code takes a user input number which is a positive integer
So, int
is actually not a restrictive enough description. In another sense, however, it is too restrictive, because your code will not just work with positive int
s but with any positive whole number type that implements __floordiv__
, __rpow__
, and __rdivmod__
.
The name bin_digits
is actively misleading: it does not contain the binary digits. It contains the number of binary digits.
Oh, and also, the term "binary digit" is usually shortened to "bit".
So, it should be called bit_length
or number_of_bits
.
Not naming
As I wrote above, naming is hard, and names are important. However, there is a flip side to that: because naming is hard and names are important, that means when you give something a name, that thing is important.
If I see something with a name, I think "naming is hard work, so if the author put in the hard work to come up with a name for this thing, then this must be an important thing".
I think at least some of the slightly awkward names in your code are caused by trying to come up with names for things that shouldn't have names or maybe shouldn't even exist. For example, I think that out_s
shouldn't be necessary.
Comments
Generally speaking, comments are a code smell. Mostly, comments should not exist:
- If your code is so complex that you need to explain it in a comment, you should rather try to refactor your code to be less complex.
- If you have a comment that explains what something is, you can rather give it a name.
- If you have a comment that explains how the code does something, just delete it: the code explains how the code does something.
The only acceptable thing for a comment is to explain why the code does something in a specific non-obvious way. So, for example, there is an obvious way that looks like it should work, but you tried it and it didn't work for a non-obvious reason. Then you can add a comment explaining that you are specifically using solution B even though it looks like much simpler solution A should also work, but it actually doesn't work because of issue X. Ideally, you would add a link to the pull request / code review / bug ticket where this issue is discussed in greater detail and maybe a link to a wiki page with a detailed explanation.
I will give a couple of examples:
#find length of bin string
bin_digits = floor(log2(int_value)) + 1
I talked before about how I think the name bin_digits
is not a very good name. And I think the comment reinforces that. Instead of using the comment to clarify the unclear name of the variable, we can rename the variable to just say that in the first place:
bit_length = floor(log2(int_value)) + 1
Here is another example:
return s[::-1] #start:stop:step slice notation
This comment is basically useless.
If I know Python, I know slice notation, so this comment tells me nothing. If I don't know Python, I have no idea what "slice notation" means, so this comment tells me nothing.
[::-1]
is the standard Python idiom for reversing a sequence. This idiom is so well-known that even I know it, even though I am not actually a Python programmer. It really needs no explanation.
For a Python programmer, the "letters" [::-1]
are actually no different from the letters reverse
.
Type annotations
There are several places in your code, where you put types either in comments or in names. If you think the types are important enough to write down at all, don't you think they shouldn't be hidden away in comments or in names? Why not write the type as a … type?
For example, this
def integer_to_binary_string(int_value):
could become this:
def integer_to_binary(n: int) -> str:
Or here, where for some strange reason you put the type not in the name but in the documentation, even though the situation is exactly the same as above:
def integer_to_reverse_binary(int_value):
'''returns a string, not a numerical'''
becomes
def integer_to_reverse_binary(n: int) -> str:
Iteration Patterns
A lot of stuff we do in programming is iteration. We are iterating over data structures, over user inputs, you can think of a reactive program as iterating over a stream of events, etc. It is no wonder that the collections framework is the centerpiece of any language's core libraries.
There are a couple of fundamental iteration patterns that occur over and over again. Some of the more well-known are fold ("folding" a collection into a single value using a binary operation) and map (transforming each individual element of the collection). Then there is scan aka prefix-sum, filter, and so on.
Of these, fold has an interesting property: it is universal and expressive, meaning that all the other ones I listed and many more (in fact, everything that can be expressed by iterating over a collection, i.e. by using Python's for
/ in
) can be expressed as a fold.
What does this have to do with this problem? Well, in turns out, fold has a dual, which is typically called unfold. Where fold "folds" a collection into a single value using a function that takes two arguments and returns one value, unfold does the exact dual of that: it "unfolds" a single value into a collection using a function that takes one argument and returns two values.
Doesn't that sound exactly what we are trying to do? Unfold a single number into a collection of ones and zeroes?
Python implements many recursion patterns in its library and even in the language. For example, functors.reduce
is fold, itertools.accumulate
is scan comprehensions are a powerful combination of map and filter.
Unfold, however, is unfortunately missing. But it is easy to write (or, as I did, google):
from typing import Callable, Iterable, Optional, Tuple, TypeVar
A = TypeVar("A")
B = TypeVar("B")
def unfold(f: Callable[[B], Optional[Tuple[A, B]]], initial: B) -> Iterable[A]:
"""
Unfold a seed into an iterable.
Unfold an initial seed value into a potentially infinite iterable by
repeatedly applying a function for generating the next element.
Implementation inspired by:
https://reddit.com/r/programming/comments/65bpf/unfold_in_python/c02vvrz/
:param f: a callable that takes the current seed as argument and
returns a tuple of the next value and the next seed (or None to
stop the generation).
:param initial: the initial seed value
:yields: a potentially infinite interable of values generated from the seed
by repeatedly applying f.
"""
intermediate = f(initial)
while intermediate:
element, initial = intermediate
yield element
intermediate = f(initial)
With this definition in scope, we can express the operation very succinctly, in my opinion:
def integer_to_binary(n: int) -> str:
"""
Convert a non-negative integer into binary representation.
:param n: a non-negative integer.
:returns: the binary representation of n.
"""
if n < 0:
raise ValueError(n)
return "".join(unfold(lambda n: (str(n % 2), n // 2) if n else None, n))[::-1]
There seem to be differing opinions in the Python community about whether or not functional programming and recursion patterns are idiomatic or not. For someone who is used to working with recursion patterns, the code here is understandable and succinct (but not dense).
Others may disagree. One way to get around this would be to possibly turn the anonymous lambda into a named nested function.
bin(...)
function, so I've added that tag. Please correct me if I'm wrong... \$\endgroup\$'0b'
from the output ofbin(i)
to satisfy the automated test, but that's trivial) \$\endgroup\$