Skip to content

max/min return wrong value if signed (negative) zero #20221

Open

Description

@kkmuffme

Description

The following code:

https://3v4l.org/JXU4T#v8.4.13

<?php
var_dump( min( round( -0.01 / 2, 0 ), 0 ) );
var_dump( max( round( -0.01 / 2, 0 ), 0 ) );

Resulted in this output:

int(0)
float(-0)

But I expected this output instead:

float(-0)
int(0)

For 99.9% of applications, this output is actually what people would expect however: (instead of -0)

float(0)
int(0)

so I guess it would make sense to also change that.

This does not have any impact on calculations or comparisons, this is purely visual but can lead to unexpected behavior if the returned number is used as a string (e.g. substr, strtok,...) since there suddenly is an additional character in front of the numbers.
This is especially unexpected, when the float is the result of float arithmetics, which can minimally negative numbers in some cases

PHP Version

8.4

Operating System

No response

Activity

  1. ndossche commented on Oct 19, 2025

    @ndossche
    Member

    Versions prior to 8.4 output:

    float(-0)
    float(-0)
    
  2. TimWolla commented on Oct 19, 2025

    @TimWolla
    Member

    Changing the order of the parameters

    <?php
    var_dump( min( 0, round( -0.01 / 2, 0 ) ) );
    var_dump( max( 0, round( -0.01 / 2, 0 ) ) );
    

    returns:

    float(-0)
    int(0)
    

    So this seems to be related to how 0.0 and -0.0 compare equal and thus the behavior is returning either the first or the last of all equal values.

  3. TimWolla commented on Oct 19, 2025

    @TimWolla
    Member

    Simpler reproducer:

    <?php
    var_dump(min(0.0, -0.0));
    var_dump(min(-0.0, 0.0));
    var_dump(max(0.0, -0.0));
    var_dump(max(-0.0, 0.0));
    
  4. TimWolla commented on Oct 19, 2025

    @TimWolla
    Member

    JavaScript:

    > Math.min(0.0, -0.0);
    -0
    > Math.min(-0.0, 0.0);
    -0
    > Math.max(-0.0, 0.0);
    0
    > Math.max(0.0, -0.0);
    0
    
  5. TimWolla commented on Oct 19, 2025

    @TimWolla
    Member

    Full test case:

    <?php
    foreach ([-0.0, 0.0] as $a) {
    	foreach ([-0.0, 0.0] as $b) {
    		if ("$a $b" === "0 0" || "$a $b" === "-0 -0") {
    			continue;
    		}
    
    		printf("min(%.17g, %.17g)   = %.17g\n", $a, $b, min($a, $b));
    		printf("min([%.17g, %.17g]) = %.17g\n", $a, $b, min([$a, $b]));
    		printf("max(%.17g, %.17g)   = %.17g\n", $a, $b, max($a, $b));
    		printf("max([%.17g, %.17g]) = %.17g\n", $a, $b, max([$a, $b]));
    		echo "\n";
    	}
    }
    
    echo "======\n\n";
    
    foreach ([-0.0, 0.0] as $a) {
    	foreach ([-0.0, 0.0] as $b) {
    		foreach ([-0.0, 0.0] as $c) {
    			if ("$a $b $c" === "0 0 0" || "$a $b $c" === "-0 -0 -0") {
    				continue;
    			}
    
    			printf("min(%.17g, %.17g, %.17g)   = %.17g\n", $a, $b, $c, min($a, $b, $c));
    			printf("min([%.17g, %.17g, %.17g]) = %.17g\n", $a, $b, $c, min([$a, $b, $c]));
    			printf("max(%.17g, %.17g, %.17g)   = %.17g\n", $a, $b, $c, max($a, $b, $c));
    			printf("max([%.17g, %.17g, %.17g]) = %.17g\n", $a, $b, $c, max([$a, $b, $c]));
    			echo "\n";
    		}
    	}
    }
    
  6. added a commit that references this issue on Oct 19, 2025
    ae9fd51
  7. eyupcanakman commented on Jul 16, 2026

    @eyupcanakman
    Contributor

    Every path keeps the first of two equal values except frameless min. The variadic loop replaces only on min_dval > Z_DVAL(args[i]), zend_hash_minmax only on compar(res, zv) > 0, and frameless max uses lhs >= rhs ? lhs : rhs. Frameless min uses lhs < rhs ? lhs : rhs, which takes the false branch on a tie and returns the second argument.

    So only min changes with the call form:

    min(0.0, -0.0)                    // -0  frameless 2-arg
    min(0.0, -0.0, 0.0)               // +0  variadic loop
    min([0.0, -0.0])                  // +0  zend_hash_minmax
    call_user_func('min', 0.0, -0.0)  // +0  dynamic call, no frameless path
    

    max agrees with itself across the forms, though keep-first stays order-dependent. max(0.0, -0.0) is +0 and max(-0.0, 0.0) is -0.

    Changing frameless min to <= would line the paths up, and every form would still flip with argument order. Order-independence needs a sign check on equal doubles, giving min = -0 and max = +0 on a signed-zero tie.

    That is the tie rule IEEE 754-2019 minimum/maximum use, and what Math.min/max implement. Would you want that, or is path agreement enough?

  8. mrinella commented on Oct 28, 2026

    @mrinella

    I hit a second form of this that isn't about signed zero and that the manual is explicit about, so it may help settle which fix to merge.

    Two equal values of different types: the manual says the first one is returned ("If multiple values of different types evaluate as equal (e.g. 0 and 'abc') the first provided to the function will be returned"). With no extensions loaded, PHP 8.4 and 8.5 return the second:

    $ php -n -r '$a = 1.0; $b = "1.0000"; var_dump(min($a, $b), max($a, $b));'
    string(6) "1.0000"
    float(1)
    

    Tested on 8.4.27 (two builds: Remi on AlmaLinux 10, Homebrew on macOS) and 8.5.12 (Homebrew). max() keeps the first; min() doesn't. The cause is the two-argument frameless fast path (ZEND_FRAMELESS_FUNCTION(min, 2) in ext/standard/array.c): on a tie its generic branch does zend_compare(lhs, rhs) < 0 ? lhs : rhs, so it returns rhs, while PHP_FUNCTION(min) only replaces on < 0 and keeps the first, and the frameless max uses >=. The same line is on master today. Before 8.4 there was no fast path, so the regular rule applied.

    Two things make this one easy to miss and worth fixing on its own:

    • Which path a call takes depends on the environment. The compiler doesn't emit the fast path when an extension overrides zend_execute_internal (Xdebug, the New Relic agent), so the same script returns the float on one machine and the string on another, and under New Relic the first min() in a process returns the float and later ones the string. php -n is the reliable reproducer.
    • Returning the other argument changes the type, not just the sign. Real impact: Magento 2.4.8 computes a credit memo quantity as min(float, "1.0000") (the second value comes from the database) and passes the result to a strict_types method typed float. On PHP 8.4, refunds through the REST API fail with a TypeError whenever the requested quantity equals the invoiced quantity, which is nearly all of them (142 of 143 in one run); the same lines on PHP 8.2 worked for years. That took us a day to trace after a production upgrade.

    #23505's change (<= in the frameless min) fixes this case as well as the signed-zero one. #20223 as written only changes the double/double branches and leaves the generic tie line alone, so it wouldn't.

    Happy to test a patch on the Remi 8.4 build if that helps. Written with the help of an LLM (Claude); the tests and the reading of the source were done on my machines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions