This is a slightly esoteric topic but one that I always find interesting, just how is PHP actually executing the scripts, classes and frameworks that we ask it to. As PHP is an interpreted language it doesn't actually compile down to machine code. Instead the scripts are compiled to opcodes, these are low level instructions that run on the PHP (Zend) engine. The Zend engine will then make calls out to the actual compiled C code that makes up PHP and all of it's various extensions. This is similar to how the Java JVM works although there's a further compilation step for Java programs that allows for more optimisations and better performance than you'd normally get from a language like PHP or Python.
We generally accept that interpreted languages are orders of magnitude slower than compiled languages. However, in web-based languages like PHP, the execution time of the language itself is rarely the primary bottleneck. Slow web requests are more often caused by network latency or poor database optimization than by waiting for code to execute within the Zend Engine.
Anyway, we're not here for a discussion on why PHP is slower than Rust, what I thought would be interesting would be to see the impact of different types of PHP code in terms of opcodes, ie how many opcodes would it take to get the same job done using different styles of coding in PHP.
First up, hello world!
<?php
echo 'Hello World!';
This results in the following opcodes
line #* E I O op fetch ext return operands
------------------------------------------------------------------
2 0 E > ECHO 'Hello+World%21'
3 1 > RETURN 1
Pretty straightforward stuff, a simple string is echo'd out and it returns 1 when complete, so just two opcodes generated.
Next we've livened things up a bit and broken Hello World! into two variables and used string concatenation to print it.
<?php
$hello = 'Hello';
$world = 'World';
echo $hello . ' ' . $world . '!';
This results in the following opcodes
line #* E I O op fetch ext return operands
----------------------------------------------------------------------
2 0 E > ASSIGN !0, 'Hello'
3 1 ASSIGN !1, 'World'
5 2 CONCAT ~4 !0, '+'
3 CONCAT ~5 ~4, !1
4 CONCAT ~6 ~5, '%21'
5 ECHO ~6
6 6 > RETURN 1
Already we can see that we've achieved the same result but we've managed to do this with 7 opcodes this time, so we can already see that using fancy code comes with a cost. The cost in this case would be immeasurably small but still, it's doing more than it was before, so that by definition will take longer.
Next up we're getting really fancy with an array and some logic to work out word case.
<?php
$helloWorld = ['h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!'];
foreach ($helloWorld as $char){
if ($char === 'h' || $char === 'w') {
echo strtoupper($char);
continue;
}
echo $char;
}
This results in the following opcodes
line #* E I O op fetch ext return operands
-----------------------------------------------------------------------
2 0 E > ASSIGN !0,
4 1 > FE_RESET_R $3 !0, ->15
2 > > FE_FETCH_R $3, !1, ->15
5 3 > IS_IDENTICAL ~4 !1, 'h'
4 > JMPNZ_EX ~4 ~4, ->7
5 > IS_IDENTICAL ~5 !1, 'w'
6 BOOL ~4 ~5
7 > > JMPZ ~4, ->13
6 8 > INIT_FCALL 'strtoupper'
9 SEND_VAR !1
10 DO_ICALL $6
11 ECHO $6
7 12 > JMP ->2
9 13 > ECHO !1
4 14 > JMP ->2
15 > FE_FREE $3
11 16 > RETURN 1
So now we have 16 opcodes, some conditional jumps and function calls, pretty impressive when we could have done the job with 2 opcodes, 1 if you don't include the return.
I think you probably get the message here, the code example is ridiculous but I bet we can all think of code we've written in the past that was broken up to satisfy SOLID principles, like that 4 case switch statement (that was very easy to read) that became a full polymorphic interface strategy pattern (across 8 files) to get that switch down to one line. Infact, let's implement this in a similar way.
<?php
interface Char
{
public function getChar(): string;
}
class Alpha implements Char
{
public function __construct(private string $char)
{}
public function getChar(): string
{
return $this->char;
}
}
class Space implements Char
{
public function getChar(): string
{
return ' ';
}
}
class Exclamation implements Char
{
public function getChar(): string
{
return '!';
}
}
$helloWorld = [
new Alpha('H'),
new Alpha('e'),
new Alpha('l'),
new Alpha('l'),
new Alpha('o'),
new Space(),
new Alpha('W'),
new Alpha('o'),
new Alpha('r'),
new Alpha('l'),
new Alpha('d'),
new Exclamation(),
];
foreach($helloWorld as $char) {
echo $char->getChar();
}
And these are the opcodes, there's a few separate blocks compiled for the various classes.
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
8 0 E > DECLARE_CLASS 'alpha'
20 1 DECLARE_CLASS 'space'
28 2 DECLARE_CLASS 'exclamation'
38 3 NEW $2 'Alpha'
4 SEND_VAL_EX 'H'
5 DO_FCALL 0
6 INIT_ARRAY ~4 $2
39 7 NEW $5 'Alpha'
8 SEND_VAL_EX 'e'
9 DO_FCALL 0
10 ADD_ARRAY_ELEMENT ~4 $5
40 11 NEW $7 'Alpha'
12 SEND_VAL_EX 'l'
13 DO_FCALL 0
14 ADD_ARRAY_ELEMENT ~4 $7
41 15 NEW $9 'Alpha'
16 SEND_VAL_EX 'l'
17 DO_FCALL 0
18 ADD_ARRAY_ELEMENT ~4 $9
42 19 NEW $11 'Alpha'
20 SEND_VAL_EX 'o'
21 DO_FCALL 0
22 ADD_ARRAY_ELEMENT ~4 $11
43 23 NEW $13 'Space'
24 DO_FCALL 0
25 ADD_ARRAY_ELEMENT ~4 $13
44 26 NEW $15 'Alpha'
27 SEND_VAL_EX 'W'
28 DO_FCALL 0
29 ADD_ARRAY_ELEMENT ~4 $15
45 30 NEW $17 'Alpha'
31 SEND_VAL_EX 'o'
32 DO_FCALL 0
33 ADD_ARRAY_ELEMENT ~4 $17
46 34 NEW $19 'Alpha'
35 SEND_VAL_EX 'r'
36 DO_FCALL 0
37 ADD_ARRAY_ELEMENT ~4 $19
47 38 NEW $21 'Alpha'
39 SEND_VAL_EX 'l'
40 DO_FCALL 0
41 ADD_ARRAY_ELEMENT ~4 $21
48 42 NEW $23 'Alpha'
43 SEND_VAL_EX 'd'
44 DO_FCALL 0
45 ADD_ARRAY_ELEMENT ~4 $23
49 46 NEW $25 'Exclamation'
47 DO_FCALL 0
48 ADD_ARRAY_ELEMENT ~4 $25
37 49 ASSIGN !0, ~4
52 50 > FE_RESET_R $28 !0, ->56
51 > > FE_FETCH_R $28, !1, ->56
53 52 > INIT_METHOD_CALL !1, 'getChar'
53 DO_FCALL 0 $29
54 ECHO $29
52 55 > JMP ->51
56 > FE_FREE $28
55 57 > RETURN 1
line #* E I O op fetch ext return operands
----------------------------------------------------------------------------
5 0 E > VERIFY_RETURN_TYPE
1 > RETURN null
line #* E I O op fetch ext return operands
-----------------------------------------------------------------------
10 0 E > RECV !0
1 ASSIGN_OBJ 'char'
2 OP_DATA !0
11 3 > RETURN null
line #* E I O op fetch ext return operands
----------------------------------------------------------------------------
15 0 E > FETCH_OBJ_R ~0 'char'
1 VERIFY_RETURN_TYPE ~0
2 > RETURN ~0
16 3* VERIFY_RETURN_TYPE
4* > RETURN null
line #* E I O op fetch ext return operands
----------------------------------------------------------------------------
24 0 E > > RETURN '+'
25 1* VERIFY_RETURN_TYPE
2* > RETURN null
line #* E I O op fetch ext return operands
-----------------------------------------------------------------------------
32 0 E > > RETURN '%21'
33 1* VERIFY_RETURN_TYPE
2* > RETURN null
Lots of memory allocations and function calls, now we're talking.
This example is extreme and opcache can hopefully optimise a lot of this stupidity away, but I've seen abstractions break out into a class structure like this to avoid bulky methods with switch statements (when I say I've 'seen' them it's because I was sat there writing them). The amount of code running through the zend engine for this implementation is laughable when you consider the same job could have been done with a single line.
So there you have it, cast iron proof that you can generate silly amounts of opcodes with a silly example. But the idea here is to show the overhead of using over-thought abstractions for problems that aren't really there. Sometimes you need this type of code as your problem domain is just too complicated to squeeze into inline code, but as with everything in software there's a trade-off, I think seeing what the php compile process actually generates is useful when thinking about this!
Photo by Paul Blenkhorn on Unsplash