기본적으로 PHP 의 배열은 일반적인 ArrayList 구현이 아니라, Hash Table 입니다.
그러다보니 php 개발자들은 배열을 배열처럼 쓰지 않고 Hash Table 처럼 이용하는 분들이 많습니다.
(나쁜거 아니에요!)
$a = ['q_lazzarus' => '킹왕짱'];
echo $a['q_lazzarus'];
다시 기초로 돌아가자면, array 는 동일한 자료구조의 반복 입니다.
메모리 단위에서 생각해보면 동일한 크기의 방이 주루룩 있는 구조이죠.
!!
그렇다면, string 하나에 integer 몰빵해서 넣으면 되자너?
구현해보자 !
5개의 원소가 있는 배열이 있다고 가정하고 데이터를 읽는 간단한 함수를 만들어 보겠습니다.
function array_get($i) {
$data = '12345';
return substr($data, $i, 1);
}
echo array_get(3);
3
짜잔! 이렇게 string 변수를 참조하여, 값을 리턴하는 간단한 코드가 만들어졌습니다.
하지만 당연하게도 이건 못 씁니다.
왜냐하면 원소의 데이터가 0~9 까지만 허용하는 말도 안되는 구현이기 때문입니다.
자료형 ??
가만히 다시 생각해 봅시다. 설마 integer 데이터를 메모리에 string 처럼 저장하지 않겠죠…
보통의 경우 integer 자료형은 4 Bytes 를 차지하고 있습니다.
Data Type | Size (in bytes) | Range |
---|---|---|
short int | 2 | -32,768 to 32,767 |
unsigned short int | 2 | 0 to 65,535 |
unsigned int | 4 | 0 to 4,294,967,295 |
int | 4 | -2,147,483,648 to 2,147,483,647 |
long int | 4 | -2,147,483,648 to 2,147,483,647 |
unsigned long int | 4 | 0 to 4,294,967,295 |
long long int | 8 | -(2^63) to (2^63)-1 |
unsigned long long int | 8 | 0 to 18,446,744,073,709,551,615 |
singed char | 1 | -128 to 127 |
unsigned char | 1 | 0 to 255 |
호오 그렇다면? 데이터를 변환해서 넣도록 하겠습니다.
다행히 php 에서는 pack 이라는 함수가 이러한 변환을 편하게 도와줍니다.
$data = str_repeat(pack('I', null), 5);
function array_get($i) {
global $data;
$binary = substr($data, $i * 4, 4);
$unpack = unpack('I', $binary);
return $unpack[1];
}
function array_set($i, $value) {
global $data;
$binary = pack('I', $value);
$data = substr_replace($data, $binary, $i * 4, 4);
}
array_set(3, 65535);
echo array_get(3);
65535
오 이번에는 10 을 넘어서 심지어 65535 도 잘 나오고 있습니다.
하지만 아직 문제가 있습니다.
echo array_get(2);
0
아니 어떻게 된 일이죠?? 분명히 공간만 만들어주기 위해서 null 을 넣어주었는데?
사실은 integer 자료형에서는 null 은 없습니다.
왜냐하면 null 은 null 이기 때문입니다. (자료형도 달라요)
그렇다보니 저희가 만든 함수에서는 null 은 처리가 불가능 합니다.
그럼 어떻게 해야할까요?
꼼수 ??
생각해보면 데이터를 넣을때 한칸씩 미루면 되지 않을까요?
$data = str_repeat(pack('I', 0), 5);
function array_get($i) {
global $data;
$binary = substr($data, $i * 4, 4);
$unpack = unpack('I', $binary);
$result = $unpack[1];
if (0 === $result) {
return null;
}
return $result - 1;
}
function array_set($i, $value) {
global $data;
if (null !== $value) {
$value = $value + 1;
}
$binary = pack('I', $value);
$data = substr_replace($data, $binary, $i * 4, 4);
}
var_dump(array_get(3));
NULL
이렇게 되면 데이터 제한이 4,294,967,295 에서 하나 소모가 되지만,
이 정도면 괜찮은 것 같습니다.
실제 배열처럼 바꿔 보아요
어느정도 된 것 같습니다. 하지만 이것을 실제 배열 처럼 쓰기에는 아직 부족합니다.
그래서 php 에서는 Array 구현하기 위한 interface 를 제공합니다.
ArrayAccess {
/* Methods */
abstract public offsetExists ( mixed $offset ) : bool
abstract public offsetGet ( mixed $offset ) : mixed
abstract public offsetSet ( mixed $offset , mixed $value ) : void
abstract public offsetUnset ( mixed $offset ) : void
}
클래스를 마치 php 기본 배열 처럼 접근 가능하게 하는 구현체이며
제한적인 의미로 accessor overloading 입니다.
$obj = new obj;
$obj[] = 'hello';
$obj[] = 'world';
echo $obj[0] . ' ' . $obj[1];
hello world
실제 구현은?
class FixedUnsignedIntegerArray implements Iterator, ArrayAccess, Countable
{
const BINARY_FORMAT = 'I';
private $position = 0;
private $length = 0;
private $binaryLength = 0;
private $data = null;
public function __construct($length)
{
$null = $this->convertBinary(null);
$this->position = 0;
$this->length = $length;
// because binary length is machine dependency
$this->binaryLength = strlen($null);
$this->data = str_repeat($null, $length);
}
private function getEntry($position)
{
$position = $position * $this->binaryLength;
$unpack = unpack(self::BINARY_FORMAT, substr($this->data, $position, $this->binaryLength));
$result = false !== $unpack ? $unpack[1] : null;
if (0 === $result) {
return null;
} else {
return $result - 1;
}
}
private function setEntry($position, $value)
{
$position = $position * $this->binaryLength;
$this->data = substr_replace($this->data, $this->convertBinary($value), $position, $this->binaryLength);
}
private function convertBinary($value)
{
if (null !== $value) {
$value = $value + 1;
}
return pack(self::BINARY_FORMAT, $value);
}
public function rewind()
{
$this->position = 0;
}
public function current()
{
return $this->getEntry($this->position);
}
public function key()
{
return $this->position;
}
public function next()
{
$this->position = $this->position + 1;
}
public function valid()
{
return $this->offsetExists($this->position);
}
public function offsetSet($offset, $value)
{
if (is_integer($offset) && $offset < $this->length && $offset >= 0) {
$this->setEntry($offset, $value);
} else {
throw new InvalidArgumentException('overflow array offset');
}
}
public function offsetExists($offset)
{
return ($offset < $this->length && $offset >= 0);
}
public function offsetUnset($offset)
{
$this->setEntry($offset, null);
}
public function offsetGet($offset)
{
return $this->getEntry($offset);
}
public function count()
{
return $this->length;
}
}
pack 함수의 설명을 다시 보자면, 환경에 따라 다른 byte 수가 나올 수 있습니다.
unsigned integer (machine dependent size and byte order)
따라서 integer byte 수를 저희가 알고 있어야 잘못된 주소를 참조하지 않도록 하겠습니다.
실제 구현에서는 Iterator, ArrayAccess, Countable 을 구현하여 좀 더 효용성을 높였습니다.
성능 테스트!
이제 구현했으니, 성능을 테스트 해보겠습니다.
테스트는 다음과 같이 진행하였습니다.
- 10000 크기의 고정 배열을 만들어서 랜덤으로 데이터를 넣었을때 메모리 사용량을 체크 하였습니다.
- 100번씩 반복문을 실행하여, 실행 시간의 평균을 체크 하였습니다.
define('ARRAY_LENGTH', 10000);
$start_memory = memory_get_usage();
$vanilla = [];
for ($i = 0; $i < ARRAY_LENGTH; $i++) {
$vanilla[] = mt_rand(0, 255);
}
$end_memory = memory_get_usage() - $start_memory;
echo "legacy array memory usage : {$end_memory}\n";
$start = microtime(true);
$result = 0;
for ($i = 0; $i < ARRAY_LENGTH; $i++) {
$result += $vanilla[$i];
}
$time_elapsed_secs = microtime(true) - $start;
echo "for iterator legacy array : {$time_elapsed_secs}\n";
$start_memory = memory_get_usage();
$entries = new \Monoless\Arrays\FixedUnsignedIntegerArray(ARRAY_LENGTH);
for ($i = 0; $i < ARRAY_LENGTH; $i++) {
$entries[$i] = mt_rand(0, 255);
}
$end_memory = memory_get_usage() - $start_memory;
echo "fixed unsigned integer array memory usage : {$end_memory}\n";
$time_total = 0;
$interval = 100;
for ($j = 0; $j < $interval; $j++) {
$start = microtime(true);
$result = 0;
for ($i = 0; $i < ARRAY_LENGTH; $i++) {
$result += $entries[$i];
}
$time_elapsed_secs = microtime(true) - $start;
$time_total += $time_elapsed_secs;
}
$time_average = $time_total / $interval;
echo "iterator speed average : {$time_average}\n";
legacy array memory usage : 528440
for iterator legacy array : 0.00035810470581055
fixed unsigned integer array memory usage : 41072
iterator speed average : 0.0068418407440186
메모리 사용량은 약 90% 개선이 되었으나 속도는 기존 배열을 이길 수가 없었네요…
역시 기존껄 쓰는거 좋은거라고 배우고… 오늘도 맥주와 치킨을 시키며 잡니다.
그래도 제 삽질이 좋다면, github 와 이 패키지를 참고하세요
후일담…
혹시나 싶어서 php://memory 에 fwrite / fseek 를 구현해봤는데 더 느렸습니다…
for iterator legacy array : 0.0013089179992676
string speed average : 0.0084279131889343
php://memory speed average : 0.0098107981681824
참조 사이트
- https://www.php.net/manual/en/language.types.array.php
- https://www.geeksforgeeks.org/array-data-structure/
- https://www.geeksforgeeks.org/c-data-types/