51代のプログラミング研究会会長をしておりました、しらすです。今回はタイトルの通り、プログラミング言語の似ているようで違う機能を比較していこうというざっくりとした企画です。そもそもプログラミング言語ごとに根本的な思想が異なるので、その一部分の機能についてだけ見て比較するのはナンセンスではあるのですが、今回はあえて比較してみることで利用者の立場から表面的な違いを知って行ければと思います。
検証環境
Ubuntu 18.04.1 LTS 64bit
| プログラミング言語 | バージョン | REPLバージョン |
|---|---|---|
| C | gcc version 7.3.0 | cling 0.6~dev |
| C# | Mono JIT compiler version 5.16.0.220 | Visual C# Interactive Compiler version 2.8.2.62916 |
| Java | openjdk version "10.0.2" | jshell 10.0.2 |
| Python3 | Python 3.7.0 | ipython 6.5.0 |
| Ruby | ruby 2.5.1p57 | irb 0.9.6 |
| PHP | PHP 7.3.0-1 | - |
| Scala | version 2.12.8 | - |
| OCaml | version 4.05.0 | - |
0.予備知識
式と文
端的に言うと値を返すのが式で、返さないのが文です。
式の例
5 // 5という値を返す式 a == b // 真偽値を返す条件"式" foo() // 関数はその返り値を値とする式 x = 3 // 代入"式"(大抵の言語でxに代入したのちx自体を返す)
文の例
// 値を返さないif"文" if(...) { } // 値を返さないwhile"文" while(...) { } x = 3; // 文として振る舞う式を式文と呼んだりする。
1. 四則演算
四則演算は大抵どの言語でも+ - * /で、使えますね。
整数同士の割り算
言語によって切り捨てたり小数点まで計算したり様々ですが、どうなるでしょうか?各言語のインタプリタで22/7を実行してみた結果を以下に示します。
| 3.1428...になる | 3になる |
|---|---|
| Python3, PHP | C, C#, Java, Ruby, Scala, OCaml |
特にPythonはPython2のときは切り捨てていたのにPython3では小数点まで計算するようになったという罠もあります。
演算子?メソッド?関数?
どの言語でも1 + 2の結果は当然3になるのですが、この+について迫ってみます。和を作り出すアプローチとして演算子・メソッド・関数の3種類があります。演算子の場合、純粋に構文として1 + 2という式で、その値が3となります。メソッドの場合、1というオブジェクトのメンバメソッドとして+メソッドが用意されており、2という引数を受け取ることで戻り値3を返します。関数の場合、+関数が組み込み関数として用意されており、1と2の2引数を受け取って戻り値3を返します。
| 演算子 | メソッド | 関数 |
|---|---|---|
| C, C#, Java, Python3, PHP | Ruby, Scala | OCaml |
メソッドであるので、RubyやScalaでは+を利用して以下のような書き方もできます。
irb(main):001:0> 1.+(2) => 3
scala> 1.+(2) res0: Int = 3
OCamlでは+はint型の引数2つを受け取ってint型の値を返す関数に過ぎず、以下のような書き方で確認できます。
# (+);; - : int -> int -> int = <fun> # (+) 1 2;; - : int = 3
2.条件分岐
条件分岐は、書き方は多少異なるにしても、大抵どの言語でもif(条件式)のような形です。
ifは文であるか式であるか
言語によって式であるか文であるかという違いがあります。文である場合、ifは値を返しません。式である場合、ifは何らかの値を返します。
| if文 | if式 |
|---|---|
| C, C#, Java, Python3, PHP | Ruby, Scala, OCaml |
ifが式であるRuby, Scalaでは以下のように条件分岐の結果を変数に代入できます。関数型言語に馴染みのない方はあまり見慣れない書き方かもしれません。
irb(main):001:0> x = 10 => 10 irb(main):002:0> res = if x % 2 == 0 then irb(main):003:1* "偶数" irb(main):004:1> else irb(main):005:1> "奇数" irb(main):006:1> end => "偶数" irb(main):007:0> res => "偶数"
scala> val x = 10
x: Int = 10
scala> val res = if(x % 2 == 10) {
| "偶数"
| } else {
| "奇数"
| }
res: String = 奇数
scala> res
res0: String = 奇数
if(0)は成立する?
if文の条件式に0を与えたときの振る舞いが面白いのでまとめておきます。
| 成立して真である | 成立して偽である | 成立しない |
|---|---|---|
| Ruby | C, Python3, PHP | C#, Java, OCaml, Scala |
intのbooleanへの暗黙的変換ができないJavaやOCamlのような言語ではそもそも成立しません。
jshell> if(0) {
...> System.out.println("ok");
...> }
| エラー:
| 不適合な型: intをbooleanに変換できません:
| if(0) {
|
# let x = if 0 then 1 else 2;;
Error: This expression has type int but an expression was expected of type
bool
intからbooleanへの暗黙的変換のできるCやPHPでは大抵0はfalseとなります。
[cling]$ #include<stdio.h>
[cling]$ if(0) {
[cling]$ ? printf("ok\n");
[cling]$ ? } else {
[cling]$ ? printf("bad\n");
[cling]$ ? }
bad
php > if(0) {
php { echo "ok\n";
php { } else {
php { echo "bad\n";
php { }
bad
php > var_dump(0 == false);
bool(true)
さて、Rubyですが、Rubyにはそもそもbooleanに該当するクラスがなく、true, falseというTrueClass, FalseClassのオブジェクトであるということを踏まえた上でif式の仕様を見てみますと、
Ruby では false または nil だけが偽で、それ以外は 0 や空文 字列も含め全て真です。 https://docs.ruby-lang.org/ja/latest/doc/spec=2fcontrol.html
とあるので、0は真となります。
irb(main):001:0> if 0 then 'ok' else 'bad' end => "ok"
3. 配列
主要な言語で配列のない言語というのは聞いたことがありませんが、その振る舞いにも要注意です。
配列の代入の振る舞い
大抵の言語は値の参照が渡される気がしますが、値渡しである言語もあります。
| 参照の値渡し | 値渡し |
|---|---|
| C, C#, Java, Python3, Ruby, Scala, OCaml | PHP |
では、Python3とPHPを比較してみます。Python3では参照値が渡されるため、元の配列への変更は代入先の配列にも影響します。
In [1]: y = x = [10, 20] In [2]: x[0] = 50 In [3]: x Out[3]: [50, 20] In [4]: y Out[4]: [50, 20]
対してPHPでは値渡しとなるため以下のようになります。
php > $y = $x = [10, 20];
php > $x[0] = 50;
php > var_dump($x);
array(2) {
[0]=>
int(50)
[1]=>
int(20)
}
php > var_dump($y);
array(2) {
[0]=>
int(10)
[1]=>
int(20)
}
PHPの場合なぜこうなるかというと、代入演算子のマニュアルに書いてありました。
PHP では通常は値による代入になりますが、オブジェクト型については例外です。 PHP 5 以降、オブジェクトは参照で代入されるようになりました。 http://php.net/manual/ja/language.operators.assignment.php
PHPでは配列型とオブジェクト型が区別されています。
4. ジェネレータ
ジェネレータは次々に値を作り出す際に便利な機能です。多くの言語に実装されています。
yieldの振る舞い
yieldの存在する言語でも、言語によって使い方が違います。
| 使い方1 | 使い方2 | yieldが存在しない |
|---|---|---|
| C#, Python3, PHP, Scala | Ruby | C, Java, OCaml |
使い方1では呼び出し元がジェネレータから値を受け取るという形を取ります。C#, Python3, PHP, Scalaについて順に見ていきます。
> IEnumerable<int> Generator()
. {
. for(int i = 0; i < 5; i++)
. {
. yield return i;
. }
. }
> foreach(int n in Generator())
. {
. Console.WriteLine(n);
. }
0
1
2
3
4
In [1]: def generator(): ...: for i in range(5): ...: yield i; ...: In [2]: for n in generator(): ...: print(n) ...: 0 1 2 3 4
php > function generator() {
php { for($i = 0; $i < 5; $i++) {
php { yield $i;
php { }
php { }
php > foreach(generator() as $n) {
php { echo($n."\n");
php { }
0
1
2
3
4
Scalaの場合、このままだと遅延評価になりませんが比較のためにあえて以下のような書き方にしてみます。
scala> def generator():Seq[Int] = for(i <- 0 until 5) yield i;
generator: ()Seq[Int]
scala> for(n <- generator()) {
| println(n);
| }
0
1
2
3
4
使い方2ではジェネレータに実行してほしい内容を呼び出し元が送るというものです。Rubyでの例を以下に示します。ブロックとしてnを表示するという処理をジェネレータに送っています。
irb(main):001:0> def generator
irb(main):002:1> (1...5).each {|i| yield i}
irb(main):003:1> end
=> :generator
irb(main):004:0> generator(){|n| puts n}
1
2
3
4
=> 1...5
ちなみにですが今回はyieldを比較するために敢えてyieldを用いていますが、Rubyでジェネレータを作る際は本来Enumeratorを使います。参考までにコードを載せておきます。
gen = Enumerator.new do |y| a = 1 loop do y << a # y.yield(a) a = a + 1 end end p gen.lazy.first(5)
5. 再帰
末尾再帰最適化(末尾呼び出し最適化)
末尾呼出し最適化(まつびよびだしさいてきか、tail call optimization)とは、末尾呼出しのコードを、戻り先を保存しないジャンプに変換することによって、スタックの累積を無くし、効率の向上などを図る手法である。末尾呼出しの除去(tail call elimination)などとも言う。
通常関数呼び出しでは、呼び出し元の情報をスタック領域に積んでいくので、関数呼び出しの階層の深さはスタック領域の容量に制限されます。特に再帰呼び出しではその性質上関数呼び出しが深くなりがちなのでスタック領域の制限に気をつけながらプログラムを記述しなければなりません。末尾呼び出し最適化に対応した言語・環境では、再帰関数を末尾呼び出しにすることで最適化の恩恵を受けられ、スタック領域の制限の問題を回避することができます。
各言語の再帰呼び出しの制限を調べてまとめてくれたサイトがあり、これを見るとなかなかに面白いです。Find limit of recursion
| 最適化する | オプションで最適化する | 最適化しない |
|---|---|---|
| Scala, OCaml, PHP | C, C#(?), Ruby | Java, Python3 |
最適化を行ってくれないJavaやPython3ではスタックが溢れてプログラムが落ちます。ちなみにですがPython3についてはdecoratorモジュールのサンプルに良さそうな解決方法があったので合わせて見ると楽しいかもしれません。
public class Main { public static long recurse(long n, long retval) { if (n == 0) return retval; return recurse(n - 1, retval + 1); } public static void main(String[] args) { System.out.println(recurse(1000, 0)); System.out.println(recurse(10000, 0)); System.out.println(recurse(100000, 0)); } } // 1000 // 10000 // Exception in thread "main" java.lang.StackOverflowError // at Main.recurse(Main.java:5) // at Main.recurse(Main.java:5) // ...
関数型プログラミングをスタンダードに行うOCamlやScalaでは末尾再帰を自然な形でよく使うため、ユーザーは末尾再帰にさえすれば自動的に最適化してくれるようになっています。当然ながら末尾再帰にしなければスタック領域を使い果たして落ちます。
let rec recurse1 n retval = if n = 0 then retval else recurse1 (n - 1) (retval + 1);; let rec recurse2 n = if n = 0 then 0 else recurse2 (n - 1) + 1;; recurse1 10000 0;; recurse1 100000 0;; recurse1 1000000 0;; recurse2 10000;; recurse2 100000;; recurse2 1000000;; (* val recurse1 : int -> int -> int = <fun> val recurse2 : int -> int = <fun> - : int = 10000 - : int = 100000 - : int = 1000000 - : int = 10000 - : int = 100000 Stack overflow during evaluation (looping recursion?). *)
object Main { def recurse1(n: Long, retval: Long): Long = { if (n == 0) return retval recurse1(n - 1, retval + 1) } def recurse2(n: Long): Long = { if (n == 0) return 0 recurse2(n - 1) + 1 } def main(a: Array[String]) = { val list = List(10000, 100000, 1000000, 10000000) list.foreach(n => println(recurse1(n, 0))) list.foreach(n => println(recurse2(n))) } } // 10000 // 100000 // 1000000 // 10000000 // 10000 // Exception in thread "main" java.lang.StackOverflowError // at Main$.recurse2(Main.scala:9) // at Main$.recurse2(Main.scala:9) // ...
また、PHPについてもPHP7から字句解析と構文解析を全て行ってAST(抽象構文木)を作成してから機械語を生成するようになった(PHP5までは字句解析・構文解析と機械語生成を同時にやっていた)らしく、末尾再帰最適化のような最適化が可能になったそうです。参考1・参考2 何だったら末尾再帰にしなくても落ちないですね。どうなっているのかは気になります。C言語(gcc)で同じコードを書いてみたらやっぱり落ちなかったので、アセンブリ吐かせて読んでみれば、どんな感じの最適化が効いているか予測できるかもとは思っています。
<?php function recurse1($n, $retval) { if ($n === 0) return $retval; return recurse1($n - 1, $retval + 1); } function recurse2($n) { if ($n === 0) return 0; return recurse2($n - 1) + 1; } echo recurse1(100000, 0)."\n"; echo recurse1(1000000, 0)."\n"; echo recurse1(10000000, 0)."\n"; echo recurse2(100000)."\n"; echo recurse2(1000000)."\n"; echo recurse2(10000000)."\n"; // 100000 // 1000000 // 10000000 // 100000 // 1000000 // 10000000
一部の言語ではユーザーが明示的にしたり、コンパイラオプションを変えたりすることで末尾再帰最適化を適用することができます。例えばC言語(gcc)では最適化オプションO2以上で有効になります。Rubyではコンパイルオプションを変更することで有効になります。C#は未検証ですが、
C#では、マイクロソフト製のコンパイラで、ターゲットがx64、かつリリースビルド(最適化が有効な状態)でのみ末尾再帰最適化が行われる。つまりデフォルトで最適化が無効となっているデバッグビルド時には末尾最適化が行われないという。
https://monobook.org/wiki/%E6%9C%AB%E5%B0%BE%E5%86%8D%E5%B8%B0%E6%9C%80%E9%81%A9%E5%8C%96#C.23
らしいです(噂話の噂話で申し訳ないです、誰か検証してほしいです)
最後に
本当は各言語ごとの多重継承に対するアプローチであるとか、クロージャとスコープの話であるとか調べて行きたかったのですが、これ以上は本当に長くなってしまいそうなのでこのあたりで筆をおかせていただきます。なかなかニッチなところをせめていけたのではないでしょうか。僕自身もまだまだ勉強中であるので、間違っている部分があったらぜひ「それは違うよ」のマサカリを@aimeまで飛ばしてくれると喜びます。明日25日は51代幹事長むさしんによる「代表としての1年を振り返って」です。お楽しみに!