A C++ nyelv evolúciója

A 2025 egy szép szám, ami felírható úgy, mint az első 9 természetes szám köbének összege. Ezt az alábbi C nyelvű programmal könnyen tudjuk ellenőrizni:

#include <stdio.h>

int main() {
  int n = 0;
  for (int i = 1; i <= 9; ++i)
    n += i * i * i;
  printf("%d\n", n);
}

Nézzünk meg erre néhány megoldást C++-ban!

Imperatív

Első próbálkozásként ezt gyakorlatilag ugyanúgy meg tudjuk írni C++-ban is: a nyelv (szinte) tartalmazza a C-t. Az alábbi megoldásban csak a kiíratás módja más egy kicsit, bár igazából - ugyan nem szokás - használhattuk volna a printf-et is.

#include <iostream>

int main() {
  int n = 0;
  for (int i = 1; i <= 9; ++i)
    n += i * i * i;
  std::cout << n << std::endl;
}

Objektum-orientált

A C++, akárcsak az Objective-C, a 80-as években keletkezett, és a legnagyobb újdonság az volt benne, hogy támogatta az objektum-orientált programozást, ami akkoriban még csak néhány nyelvben volt meg, többek közt a Smalltalkban. A fenti program GNU Smalltalkban így nézne ki:

| n |
n := (Interval from: 0 to: 9) fold: [:sum :x | sum + (x raisedTo: 3)].
Transcript show: n printString; cr.

Először készít egy objektumot, ami a 0-tól 9-ig való intervallumot reprezentálja, majd erre meghívja a fold metódust, ami a paraméterként kapott műveletet (ami az első paraméterhez hozzáadja a második köbét) berakja az egyes elemek közé:

(..((0 + 1^3) + 2^3)..) + 9^3

Ezt megfogalmazhatjuk C++-ban is:

#include <iostream>

struct Interval {
  int from, to;
  int fold(int (*f)(int sum, int x)) {
    if (from == to)
      return from;
    int result = f(from, from + 1);
    for (int i = from + 2; i <= to; ++i)
      result = f(result, i);
    return result;
  }
};

int addCube(int sum, int x) {
  return sum + x * x * x;
}

int main() {
  int n = Interval{0, 9}.fold(addCube);
  std::cout << n << std::endl;
}

Ez még mindig nagyon közel van a C-hez, de látjuk, hogy a struktúrához lehet metódusokat rendelni - ezek olyan függvények, amelyek látják a struktúra adattagjait. Itt a fold metódus egy függvénypointert kap, és azzal a fent illusztrált módon kiszámítja az eredményt.

Természetesen az objektum-orientált programozás nem csak a metódusokról szól. Ha jobban szeretnénk követni a Smalltalk programot, valójában egy tetszőleges típusú elemeken végigmenő Iterable sablon osztályhoz kellene rendelni a fold metódust, amit az Interval örökléssel tudna specializálni… de ez túlmutat ezen a kis bemutatón.

Funkcionális

Egy másik nagyon kedvelt klasszikus paradigma a funkcionális programozás, ami a 2000-es évektől kezdve lassanként elkezdett beszivárogni a “mainstream” nyelvekbe is, és ma már teljesen általánossá vált.

A programnak egy lehetséges implementációja Haskellben az alábbi:

main = putStrLn $ show n
  where n = sum $ map (^3) [1..9]

A végéről érdemes kezdeni. Az [1..9] elkészít egy listát, amelyben a számok szerepelnek 1-től 9-ig. A map ezután a köbre emelést alkalmazza minden egyes elemre; ez a hatványozás operátorból (^) képzett parciális alkalmazásként van megvalósítva. Végül az így kapott, köbszámokat tartalmazó listát összegezzük.

Ugyanez (C++11-es szabványú) C++-ban így nézhet ki:

#include <algorithm>
#include <array>
#include <functional>
#include <iostream>
#include <numeric>

using namespace std::placeholders;

int power(int n, int k) {
  int result = 1;
  for (int i = 0; i < k; ++i)
    result *= n;
  return result;
}

int main() {
  std::array<int, 9> xs, cubes;
  std::iota(xs.begin(), xs.end(), 1);
  std::transform(xs.begin(), xs.end(), cubes.begin(), std::bind(power, _1, 3));
  auto n = std::accumulate(cubes.begin(), cubes.end(), 0);
  std::cout << n << std::endl;
}

A power függvény akár C is lehetne, kiszámolja n-nek a k-adik hatványát. Ezt nyilván lehetne ügyesebben is, de ehhez a példához most ez is megfelel.

Itt is az első lépés a számokat tartalmazó tömb elkészítése. A C-s fix méretű tömb megfelelője a standard libraryban található array. Ez összefogja az adatpointert és a tömb méretét, amit eddig külön voltunk kénytelenek használni. Mivel ez egy általános (generikus) konstrukció, sablonparaméterekkel mondjuk meg, hogy most mi 9 db. int értékre akarjuk használni. A iota függvény feltölti ezt az elejétől a végéig, 1-től kezdve növekvő számokkal.

A transform végigmegy a megadott intervallumon (ami jelen esetben az xs tömb az elejétől a végéig), minden elemre meghív egy függvényt, és az eredményt bepakolgatja a megadott helytől kezdve folyamatosan. Ez a hely most a cubes nevű tömb eleje, a függvény pedig itt is a hatványozás parciális alkalmazása: a bind segítségével lekötjük a második paramérert, hogy mindig 3 legyen.

Végül már csak össze akarjuk adni a köbszámokat. Ehhez az accumulate-et használjuk, ami egy adott kezdőértékhez (itt 0) hozzáad minden elemet.

Tömb-alapú

A tömb-alapú nyelvek már a 60-as években megjelentek, kezdve az APL-el. Ezek lényege, hogy az alapvető típus a tetszőleges dimenziójú tömb, és a függvények ezek különböző szintjein értelmezhetőek (tehát például az összeadás működik számokra, vektorokra, mátrixokra stb., vagy akár egy többdimenziós mátrix almátrixaira is).

Ez az ötlet bekerült a MATLAB-ba és a statisztikában gyakorta használt R nyelvbe is. Az utóbbi években látszik egy nyitás ebbe az irányba a “mainstream” nyelveknél is.

Nézzük meg a problémánk APL megoldását:

+/3*⍨⍳9
)OFF

A ⍳9 (olvasd: iota 9, innen a fenti iota függvény neve) elkészíti a számokat 1-től 9-ig. A * operátor a hatványozás, általánosan n*k az n-nek a k-adik hatványa. A viszont megfordítja az előtte levő operátor paramétereinek sorrendjét, ugyanezt tehát k*⍨n alakban is felírhatjuk.

A 3*⍨⍳9 jelentése tehát annyi, hogy a 3*⍨ (köbre emelés) függvényt az 1-től 9-ig való számokat tartalmazó vektorra alkalmazzuk.

Végül a / az előtte levő operátort a fold-hoz hasonlóan berakja a vektor elemei (mátrix sorai stb.) közé. Ez az operátor itt az összeadás, a +/ tehát a (köböket tartalmazó) vektor összegét adja.

Ennek megfelel nagyjából az alábbi (C++23 szabványt alkalmazó) megoldás:

#include <algorithm>
#include <functional>
#include <iostream>
#include <ranges>

int power(int n, int k) {
  int result = 1;
  for (int i = 0; i < k; ++i)
    result *= n;
  return result;
}

int main() {
  auto r =
    std::ranges::iota_view(1, 10)
    | std::views::transform([](int k) { return power(k, 3); });
  int n = std::ranges::fold_left(r, 0, std::plus<int>());
  std::cout << n << std::endl;
}

A power függvény nem változott. A iota_view elkészíti az 1-től 9-ig való intervallumot, majd a | (olvasd: pipe) operátor ennek az eredményét összeköti a transform függvénnyel, ami hasonló az előző verzióban látotthoz, azonban itt nem egy parciális alkalmazást adunk át, hanem egy anonim (lambda) függvényt: ez egy olyan függvény, amit csak itt használunk, ezért még nevet sem adunk neki.

Az így kapott r objektum valójában nem tartalmazza a köbszámokat, csak “tudja”, hogy hogyan kell kiszámolni őket - ezt lusta kiértékelésnek hívják, és ilyen szempontból ez közelebb áll a fenti Haskell megoldáshoz.

A fold_left a korábbi accumulate általánosítása: a 0 kezdőértéken kívül átadjuk még az elvégzendő műveletet is, ami itt az összeadás.

Konklúzió

Ahogy a fentiekből látszik, a C++ nyelv nagyon sokat változott az elmúlt évtizedek alatt (kissé a szintaxis kárára), és eközben magába szívta az éppen népszerű paradigmákat is, így egy-egy problémát sokféle módon meg lehet oldani.