3
\$\begingroup\$

I've been tasked with writing a function that executes a passed function every interval, passed to the function by the user. Below is the code, and how to call it:

import time
from typing import Callable

last_milliseconds: int = 0
def set_timer(interval: int, function: Callable) -> bool:
    global last_milliseconds
    milliseconds: int = int(round(time.time() * 1000))
    done: bool = (milliseconds - last_milliseconds) >= interval
    if done:
        function()
        last_milliseconds = milliseconds

Called by:

p: Callable = lambda: print("set_timer called.")

while True:
    set_timer(1000, p)

Is there a better way to go about this? I'm unsure with the use of the global variable, and the function must be called in an infinite loop structure in order to work. Any and all feedback is accepted and appreciated.

\$\endgroup\$
4
\$\begingroup\$

First of all, your function doesn't return anything and so you can remove -> bool since it only creates confusion. You can either use -> None or use nothing at all. Still related to type annotations, I find it kinda hard to read your code with annotations everywhere. This is subjective and it's just my personal preference but I usually tend to use type annotations only when dealing with function / class definitions and their arguments.

For example, milliseconds: int = int(round(time.time() * 1000)) it's obviously going to be an integer since we can see the cast to int(). More, round() already returns an int in your case so that can be removed as well. The done variable can also be omitted and added directly to the condition itself:

def set_timer(interval: int, function: Callable) -> None:
    global last_milliseconds
    milliseconds = round(time.time() * 1000)
    
    if milliseconds - last_milliseconds >= interval:
        function()
        last_milliseconds = milliseconds

You can also remove the parentheses from your condition since - has a higher precedence than >=.


Strictly speaking about the implementation, if you're not tied to using a function and want to get rid of the usage of global, I'd use a class:

from time import time
from typing import Callable


class Timer:
    def __init__(self):
        self.last_milliseconds = 0

    def set(self, interval: int, function: Callable) -> None:
        milliseconds = round(time() * 1000)

        if milliseconds - self.last_milliseconds >= interval:
            function()
            self.last_milliseconds = milliseconds

Which can be used like this:

def main():
    timer = Timer()
    while True:
        timer.set(1000, lambda: print("set_timer called."))


main()

Or, even better, you can have a static class if your workflow allows it:

class StaticTimer:
    last_milliseconds = 0

    @classmethod
    def set(cls, interval: int, function: Callable) -> None:
        milliseconds = round(time() * 1000)

        if milliseconds - cls.last_milliseconds >= interval:
            function()
            cls.last_milliseconds = milliseconds

Which you'd call like this:

def main():
    while True:
        StaticTimer.set(1000, lambda: print("set_timer called."))


main()

The difference between the two classes is that you can't have multiple of the latter because of the class attribute and @classmethod. So even if you made multiple instance of the class, they'd all share that value.

\$\endgroup\$
3
  • 3
    \$\begingroup\$ You can either use -> None or use nothing at all - Kind of not really. Omitting a return type leaves it undefined and does not imply None. \$\endgroup\$
    – Reinderien
    Jan 3 at 17:42
  • 1
    \$\begingroup\$ @Reinderien that's a valid point. That's why I said it's a matter of preference on my side but I might've been a bit unclear ^_^ Thanks for pointing that out \$\endgroup\$ Jan 3 at 20:24
  • \$\begingroup\$ This is a bit over the top, taking into account the complexity of the task. This is all super solid advice in some situations, but in this instance it's beside the point in my opinion. OP can simply use time.sleep() and move the loop into a function. \$\endgroup\$
    – Felix
    Jan 6 at 13:37
0
\$\begingroup\$

I would approach this a bit differently. Let's employ a bit of design to construct our function. What does the user of the function choose, versus us that write it? From your question we can gather that the user chooses a callable, the interval and maybe whether it is called indefinitely or stopped at some point. This becomes our starting point when we define a function.

def repeat_call(func: Callable, interval_ms: int, n_times: int = None) -> None:
    ...

Now all that's left is defining the function body, which can be arbitrarily complicated. In this case it isn't, but there's a much better way of doing it. If you insert a print statement to your while loop, you can see that it runs a bunch of times without calling the function. This is quite wasteful. Instead, we can wait for a certain period of time and repeat. Depending on the final limit we either check for stopping or never stop.

def repeat_call(func: Callable, interval_ms: int, n_times: int = None) -> None:
    if n_times is not None:
        for i in range(n_times):
            func()
            time.sleep(interval_ms / 1000)
    else:
        while True:
            func()
            time.sleep(interval_ms / 1000)

We still have some work to do, since the function is basically doing the same thing in two different places. We can take advantage of the fact that integers cannot overflow in Python to construct a conditionally infinite loop.

def repeat_call(func: Callable, interval_ms: int, n_times: int = None) -> None:
    if n_times is None:
        n_times = -1

    while n_times != 0:
        func()
        n_times -= 1
        time.sleep(interval_ms / 1000)

And to check to see if it works:

repeat_call(p, 1000, 3)
print('Done, starting infinite:')
repeat_call(p, 1000)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.