Marmelab Blog

Converting the Symfony Stopwatch Component from PHP to Zephir

What is Zephir?

During the last HackDay at Marmelab, I wanted to give Zephir a try. Zephir is a new language which eases the creation of C extensions for PHP. The Zephir syntax is very close to PHP but it is both static and dynamic typed.

Why the Symfony Stopwatch Component?

I choose to convert the Symfony Stopwatch Component because it is the lightest component of the Symfony Framework, and Zephir is still very alpha.

Zephir

Code extract from the official Zephir documentation

1. Bootstrapping the Zephir project

First of all, I renamed all PHP files by changing their extension from *.php to *.zep, then lowercased the full pathname as Zephir recognizes only lowercase filenames. I also split stopwatch.zep into 2 files, as it contains 2 classes (Stopwatch and Section).

More details in the Zephir documentation: http://zephir-lang.com/language.html#organizing-code-in-files-and-namespaces

2. Declaring variables

Zephir handles both dynamic and static variables but we have to declare these variables before using them. The keyword var is used for dynamic typed variable, whereas uint is used for static variable (I'm sure you can guess the type it refers to).

--- stopwatchevent.zep
+++ stopwatchevent.zep
@@ -173,6 +173,9 @@
      */
     public function getDuration()
     {
+        uint $total;
+        var $period;
+
         $total = 0;
         foreach ($this->periods as $period) {
             $total += $period->getDuration();

More details in the Zephir documentation: http://zephir-lang.com/language.html#variable-declarations

3. Variable Assignment

In Zephir each variable assignment is prepended with let, just like in bash.

--- stopwatchperiod.zep
+++ stopwatchperiod.zep
@@ -30,9 +30,9 @@
      */
     public function __construct($start, $end)
     {
-        $this->start = (integer) $start;
-        $this->end = (integer) $end;
-        $this->memory = memory_get_usage(true);
+        let $this->start = (integer) $start;
+        let $this->end = (integer) $end;
+        let $this->memory = memory_get_usage(true);
     }
 /**

Furthermore, and at the time I'm writing this post, Zephir cannot handle chained assignment, so let's unchain these ones.

--- stopwatch.zep
+++ stopwatch.zep
@@ -30,7 +30,8 @@
 public function __construct()
 {

- $this->sections = $this->activeSections = array('root' => new Section('root')); + let $this->activeSections = array('root' => new Section('root')); + let $this->sections = activeSections; }

 /**

More details in the Zephir documentation: http://zephir-lang.com/types.html#types

4. Ternary Operator

Zephir does not support ternary operators (yet?), so we have to bypass this limitation by converting ternary expressions to ifstatements.

--- stopwatch.zep
+++ stopwatch.zep
@@ -173,7 +173,11 @@
      */
     public function __construct($origin = null)
     {
-        $this->origin = is_numeric($origin) ? $origin : null;
+        if (is_numeric($origin)) {
+            $this->origin = $origin;
+        } else {
+            $this->origin = null;
+        }
     }
 /**

More details in the Zephir documentation: http://zephir-lang.com/control.html#if-statement

5. Using Strings

As in C, Zephir handles string and char differently. If we use a string we have to enclose it in ", If we use a char the delimiting character is '.

--- stopwatch.zep
+++ stopwatch.zep
@@ -273,7 +273,7 @@
     public function stopEvent($name)
     {
         if (!isset($this->events[$name])) {
-            throw new \LogicException(sprintf('Event "%s" is not started.', $name));
+            throw new \LogicException(sprintf("Event \"%s\" is not started.", $name));
         }
     return $this->events[$name]->stop();

More details in the Zephir documentation: http://zephir-lang.com/types.html#string

6. Fully Qualified Namespaces

Because Zephir does not recognize relative namespaces, we have to translate usages to fully qualified namespaces.

--- stopwatchevent.zep
+++ stopwatchevent.zep
@@ -101,7 +101,7 @@
             throw new \LogicException('stop() called but start() has not been called before.');
         }

- $this->periods[] = new StopwatchPeriod(arraypop($this->started), $this->getNow()); + $this->periods[] = new \Symfony\Component\Stopwatch\StopwatchPeriod(arraypop($this->started), $this->getNow());

     return $this;
 }

7. Type Hints

In order to optimize memory consumption, we can tell Zephir which type a method parameter requires, and which type a function returns.

--- section.zep
+++ section.zep
@@ -270,7 +270,7 @@
      *
      * @throws \LogicException When the event has not been started
      */
-    public function stopEvent($name)
+    public function stopEvent(string $name) -> <Symfony\Component\Stopwatch\StopwatchEvent>
     {
         if (!isset($this->events[$name])) {
             throw new \LogicException(sprintf('Event "%s" is not started.', $name));

8. Bypassing "identical to null" Statement

Another thing Zephir cannot parse yet is a comparison with null using an identical operator. One easy way to allow compilation in this case is to use the is_null() function instead.

--- stopwatch.zep
+++ stopwatch.zep
@@ -44,7 +44,7 @@
     {
         $current = end($this->activeSections);

- if (null !== $id && null === $current->get($id)) { + if (!isnull($id) && isnull($current->get($id))) { throw new \LogicException(sprintf('The section "%s" has been started at an other level and can not be opened.', $id)); }

9. Initializing Properties in a Constructor

Zephir doesn't handle property assignment, so we have to initialize these properties within the __constuct() method. This is theexact opposite of what has been done to Symfony recently.

--- section.zep
+++ section.zep
@@ -149,7 +149,7 @@
     /**
      * @var StopwatchEvent[]
      */
-    private $events = array();
+    private $events;
 /**
  * @var null|float

@@ -173,6 +173,7 @@ */ public function _construct($origin = null) { + $this->events = []; $this->origin = isnumeric($origin) ? $origin : null; }

Let's play with it

1. Compiling step

Once all the translation of the Symfony Stopwatch Component is done, we can compile our fresh new Zephir project. This step will create the symfony.so file corresponding to the resulting PHP extension.

cd /root/
zephir init symfony
cd symfony/
zephir compile

2. Enabling the new Symfony module

Now that the symfony.so module exists, we have to update PHP configuration in order to enable it.

echo "extension=/root/symfony/ext/modules/symfony.so" > /etc/php5/mods-available/symfony.ini
php5enmod symfony

3. Is it working?

If everything works fine, then pasting the following code in your terminal will print out 2 durations (~200 ms. and 400 ms.). You can notice that we do not include the usual /vendor/autoload.php....

php << 'EOF'
<?php

use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\Stopwatch\StopwatchEvent; use Symfony\Component\Stopwatch\StopwatchPeriod;

$stopwatch = new Stopwatch(); $stopwatch->start('eventA'); usleep(200000); $stopwatch->start('eventB'); usleep(200000); $eventA = $stopwatch->stop('eventA'); $eventB = $stopwatch->stop('eventB');

printf("Duration of event A: %u ms.\n", $eventA->getDuration()); printf("Duration of event B: %u ms.\n", $eventB->getDuration());

EOF

4. Is it really working?

You can go further and run the original testsuite. But before doing it, you have to skip test doing Mocking against the current component, because it is not possible to mock a loaded extension.

--- /root/vendor/symfony/stopwatch/Symfony/Component/Stopwatch/Tests/StopwatchTest.php
+++ /root/vendor/symfony/stopwatch/Symfony/Component/Stopwatch/Tests/StopwatchTest.php
@@ -48,6 +48,7 @@ class StopwatchTest extends \PHPUnit_Framework_TestCase
 public function testIsNotStartedEvent()
 {

+ $this->markTestSkipped(); $stopwatch = new Stopwatch();

     $sections = new \ReflectionProperty('Symfony\Component\Stopwatch\Stopwatch', 'sections');

Then we can run the test suite with the following command:

phpunit --colors /root/vendor/symfony/stopwatch/Symfony/Component/Stopwatch/

Unfortunately, the test suite fails because 2 LogicException are thrown and I still haven't found how to bypass this error. But because we are positive minded, let's turn this problem into an exercise. Go fork the repository and try solving this issue!

PHPUnit 3.7.28 by Sebastian Bergmann.

...............S...EE.

Time: 1.35 seconds, Memory: 2.75Mb

There were 2 errors:

  1. Symfony\Component\Stopwatch\Tests\StopwatchTest::testSection LogicException: Event "section.child" is not started.

/root/vendor/symfony/stopwatch/Symfony/Component/Stopwatch/Tests/StopwatchTest.php:115

  1. Symfony\Component\Stopwatch\Tests\StopwatchTest::testReopenASection LogicException: Event "section.child" is not started.

/root/vendor/symfony/stopwatch/Symfony/Component/Stopwatch/Tests/StopwatchTest.php:139

FAILURES! Tests: 22, Assertions: 30, Errors: 2, Skipped: 1.

Conclusion

This extension is much faster on a vagrant box than the original component (written in PHP), probably because Vagrant's default filesystem is quite slow. Runing the extension within a docker container is slightly slower with the extension (this is weird). Benchmarking is not an easy task and as the Symfony Stopwatch Component is a really simple one, I don't think benchmarking is relevant here.

Zephir is far from being production ready, but development is very active and still full of promises. You really should keep an eye on this project. The source code of the translation of Symfony Stopwatch Component from PHP to Zephir is available on my repository on github.