İşlevsel dillerde 'Kalıp Eşleştirme' nedir?


Yanıtlar:


142

Desen eşleştirmesini anlamak, üç bölümün açıklanmasını gerektirir:

  1. Cebirsel veri türleri.
  2. Desen eşleştirme nedir
  3. Neden harika.

Özetle cebirsel veri türleri

ML benzeri işlevsel diller, "ayrık birleşimler" veya "cebirsel veri türleri" adı verilen basit veri türlerini tanımlamanıza olanak tanır. Bu veri yapıları basit kaplardır ve özyinelemeli olarak tanımlanabilir. Örneğin:

type 'a list =
    | Nil
    | Cons of 'a * 'a list

yığın benzeri bir veri yapısı tanımlar. Bunu bu C # ile eşdeğer olarak düşünün:

public abstract class List<T>
{
    public class Nil : List<T> { }
    public class Cons : List<T>
    {
        public readonly T Item1;
        public readonly List<T> Item2;
        public Cons(T item1, List<T> item2)
        {
            this.Item1 = item1;
            this.Item2 = item2;
        }
    }
}

Bu nedenle, Consve Niltanımlayıcılar basit bir yapıcıyı of x * y * z * ...ve bazı veri türlerini tanımlayan basit bir sınıfı tanımlar. Yapıcıya giden parametreler isimsizdir, konum ve veri türüne göre tanımlanırlar.

a listSınıfınızın örneklerini şu şekilde oluşturursunuz :

let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))

Aşağıdakilerle aynıdır:

Stack<int> x = new Cons(1, new Cons(2, new Cons(3, new Cons(4, new Nil()))));

Özetle desen eşleştirme

Örüntü eşleştirme bir tür tip testidir. Diyelim ki yukarıdaki gibi bir yığın nesnesi oluşturduk, yığını gözetlemek ve açmak için aşağıdaki gibi yöntemler uygulayabiliriz:

let peek s =
    match s with
    | Cons(hd, tl) -> hd
    | Nil -> failwith "Empty stack"

let pop s =
    match s with
    | Cons(hd, tl) -> tl
    | Nil -> failwith "Empty stack"

Yukarıdaki yöntemler, aşağıdaki C # ile eşdeğerdir (bu şekilde uygulanmamasına rağmen):

public static T Peek<T>(Stack<T> s)
{
    if (s is Stack<T>.Cons)
    {
        T hd = ((Stack<T>.Cons)s).Item1;
        Stack<T> tl = ((Stack<T>.Cons)s).Item2;
        return hd;
    }
    else if (s is Stack<T>.Nil)
        throw new Exception("Empty stack");
    else
        throw new MatchFailureException();
}

public static Stack<T> Pop<T>(Stack<T> s)
{
    if (s is Stack<T>.Cons)
    {
        T hd = ((Stack<T>.Cons)s).Item1;
        Stack<T> tl = ((Stack<T>.Cons)s).Item2;
        return tl;
    }
    else if (s is Stack<T>.Nil)
        throw new Exception("Empty stack");
    else
        throw new MatchFailureException();
}

(Neredeyse her zaman, makine öğrenimi dilleri çalışma zamanı tip testleri veya yayınlar olmadan kalıp eşleştirmesi uygular , bu nedenle C # kodu biraz aldatıcıdır. Uygulama ayrıntılarını biraz el sallayarak bir kenara bırakalım lütfen :))

Özetle veri yapısı ayrıştırması

Tamam, gözetleme yöntemine geri dönelim:

let peek s =
    match s with
    | Cons(hd, tl) -> hd
    | Nil -> failwith "Empty stack"

İşin püf noktası hdve tltanımlayıcıların değişkenler olduğunu anlamaktır (errm ... değişmez olduklarından, gerçekte "değişkenler" değiller, "değerler";)). Eğer stürü vardır Cons, o zaman yapıcı dışına değerlerini sökeceksin ve bağlama onları adlı değişkenlere ediyoruz hdve tl.

Desen eşleştirme yararlıdır çünkü bir veri yapısını içeriği yerine şekline göre ayrıştırmamıza izin verir . Bir ikili ağacı aşağıdaki gibi tanımladığımızı hayal edin:

type 'a tree =
    | Node of 'a tree * 'a * 'a tree
    | Nil

Bazı ağaç dönüşlerini şu şekilde tanımlayabiliriz :

let rotateLeft = function
    | Node(a, p, Node(b, q, c)) -> Node(Node(a, p, b), q, c)
    | x -> x

let rotateRight = function
    | Node(Node(a, p, b), q, c) -> Node(a, p, Node(b, q, c))
    | x -> x

( let rotateRight = functionYapıcı, sözdizimi şekeridir let rotateRight s = match s with ....)

Bu nedenle, veri yapısını değişkenlere bağlamanın yanı sıra, onu ayrıntılı olarak inceleyebiliriz. Diyelim ki bir düğümümüz var let x = Node(Nil, 1, Nil). Dediğimiz takdirde rotateLeft x, test ettiğimiz xdoğru çocuk türüne sahip olduğundan eşleştirememişse ilk desen, karşı Nilyerine Node. x -> xHerhangi bir girdi ile eşleşecek ve onu değiştirilmemiş olarak döndürecek olan bir sonraki modele geçecektir.

Karşılaştırma için yukarıdaki yöntemleri C # ile şöyle yazardık:

public abstract class Tree<T>
{
    public abstract U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc);

    public class Nil : Tree<T>
    {
        public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
        {
            return nilFunc();
        }
    }

    public class Node : Tree<T>
    {
        readonly Tree<T> Left;
        readonly T Value;
        readonly Tree<T> Right;

        public Node(Tree<T> left, T value, Tree<T> right)
        {
            this.Left = left;
            this.Value = value;
            this.Right = right;
        }

        public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
        {
            return nodeFunc(Left, Value, Right);
        }
    }

    public static Tree<T> RotateLeft(Tree<T> t)
    {
        return t.Match(
            () => t,
            (l, x, r) => r.Match(
                () => t,
                (rl, rx, rr) => new Node(new Node(l, x, rl), rx, rr))));
    }

    public static Tree<T> RotateRight(Tree<T> t)
    {
        return t.Match(
            () => t,
            (l, x, r) => l.Match(
                () => t,
                (ll, lx, lr) => new Node(ll, lx, new Node(lr, x, r))));
    }
}

Cidden.

Desen eşleştirme harika

Ziyaretçi desenini kullanarak C # 'da desen eşleştirmeye benzer bir şey uygulayabilirsiniz , ancak karmaşık veri yapılarını etkili bir şekilde ayrıştıramayacağınız için neredeyse esnek değildir. Dahası, desen eşleştirmeyi kullanıyorsanız , derleyici size bir vakayı bırakıp bırakmadığınızı söyleyecektir . Bu ne kadar harika?

C # veya kalıp eşleştirmesi olmayan dillerde benzer işlevleri nasıl uygulayacağınızı düşünün. Çalışma zamanında test testleri ve yayınlar olmadan bunu nasıl yapacağınızı düşünün. Kesinlikle zor değil , sadece hantal ve hantal. Ve her vakayı ele aldığınızdan emin olmak için derleyiciyi kontrol etmezsiniz.

Bu nedenle desen eşleştirme, veri yapılarını çok uygun, kompakt bir sözdiziminde ayrıştırmanıza ve gezinmenize yardımcı olur, derleyicinin kodunuzun mantığını en azından biraz kontrol etmesini sağlar . Gerçekten olduğunu bir katil özelliği.


+1 ancak Mathematica gibi kalıp eşleştirmeye sahip diğer dilleri unutmayın.
JD

1
değişkenleri 'ama "değerleri 'Bunların sabit olduğumuza errm ... onlar gerçekten konum'';)" Onlar şunlardır değişkenler; yanlış etiketlenen değişken çeşittir . Yine de mükemmel cevap!
Doval

3
"Neredeyse her zaman, makine öğrenimi dilleri, çalışma zamanı tip testleri veya yayınlar olmadan kalıp eşleştirmesi uygular" <- Bu nasıl çalışır? Beni bir astara yönlendirebilir misin?
David Moles

1
@DavidMoles: Tür sistemi, kalıp eşleşmelerinin kapsamlı olduğunu ve gereksiz olmadığını kanıtlayarak tüm çalışma zamanı kontrollerini ortadan kaldırmayı mümkün kılar. SML, OCaml veya F # gibi bir dil, kapsamlı olmayan veya fazlalık içeren bir kalıp eşleşmesi sağlamaya çalışırsanız, derleyici sizi derleme sırasında uyaracaktır. Bu son derece güçlü bir özelliktir çünkü kodunuzu yeniden düzenleyerek çalışma zamanı kontrollerini ortadan kaldırmanıza izin verir, yani kodunuzun kanıtlanmış yönlerine sahip olabilirsiniz. Dahası, anlaşılması kolaydır!
JD

@JonHarrop Bunun nasıl çalışacağını görebiliyorum (etkili bir şekilde dinamik mesaj göndermeye benzer) ancak çalışma zamanında bir tür testi olmadan bir dalı nasıl seçtiğinizi göremiyorum.
David Moles

33

Kısa cevap: Desen eşleştirme, işlevsel dillerin eşittir işaretini atama yerine bir eşdeğerlik iddiası olarak ele alması nedeniyle ortaya çıkar .

Uzun cevap: Kalıp eşleştirme, verilen değerin "şekline" dayalı bir gönderim şeklidir. İşlevsel bir dilde, tanımladığınız veri türleri genellikle ayrıştırılmış birleşimler veya cebirsel veri türleri olarak bilinenlerdir. Örneğin, (bağlantılı) liste nedir? Bir Listtürden bağlantılı şeylerin listesi a, ya boş liste Nilya da a Consa üzerine ed türündeki bazı öğelerdir List a(bir liste a). Haskell'de (en aşina olduğum işlevsel dil), bunu yazıyoruz

data List a = Nil
            | Cons a (List a)

Ayrımcılığa uğramış tüm sendikalar şu şekilde tanımlanır: tek bir tür, onu oluşturmak için sabit sayıda farklı yola sahiptir; Nilve Consburadaki gibi yaratıcılara kurucular denir. Bu, türün bir değerinin List aiki farklı kurucu ile yaratılmış olabileceği anlamına gelir - iki farklı şekle sahip olabilir. headListenin ilk elemanını elde etmek için bir fonksiyon yazmak istediğimizi varsayalım . Haskell'de bunu şöyle yazardık

-- `head` is a function from a `List a` to an `a`.
head :: List a -> a
-- An empty list has no first item, so we raise an error.
head Nil        = error "empty list"
-- If we are given a `Cons`, we only want the first part; that's the list's head.
head (Cons h _) = h

Yana List adeğerleri, iki farklı olabilir, ayrı ayrı her bir işlemek gerekir; bu desen eşleştirmedir. İçinde head x, xkalıpla Nileşleşirse, ilk durumu çalıştırırız; kalıpla eşleşirse Cons h _, ikinciyi çalıştırırız.

Kısa cevap, açıklandı: Bence bu davranış hakkında düşünmenin en iyi yollarından biri, eşittir işaretini nasıl düşündüğünüzü değiştirmektir. Kıvırcık braket dillerde, genel olarak, =: atama gösterir a = baraçlar “markasını aiçine b.” Bununla birlikte, birçok işlevsel dilde =bir eşitlik iddiasını ifade eder: soldaki şeyin sağdakine eşdeğer let Cons a (Cons b Nil) = frob x olduğunu iddia eder ; ayrıca solda kullanılan tüm değişkenler görünür hale gelir. Fonksiyon argümanlarında olan da budur: ilk argümanın benzediğini iddia ediyoruz ve eğer yoksa, kontrol etmeye devam ediyoruz.Cons a (Cons b Nil)frob xNil


Eşittir işareti hakkında ne kadar ilginç bir düşünme şekli. Bunu paylaştığınız için teşekkürler!
jrahhali

2
Ne anlama Consgeliyor?
Roymunson

2
@Roymunson: Consolduğu olumsuz yönleri bir kafa üzerinden bir (bağlanmış) listesi oluşturur tructor ( a) ve bir kuyruk ( List a). İsim Lisp'ten geliyor. Haskell'de yerleşik liste türü için bu :operatördür (hala "eksiler" olarak telaffuz edilir).
Antal Spector-Zabusky

23

Yazmak yerine

double f(int x, int y) {
  if (y == 0) {
    if (x == 0)
      return NaN;
    else if (x > 0)
      return Infinity;
    else
      return -Infinity;
  } else
     return (double)x / y;
}

Yazabilirsin

f(0, 0) = NaN;
f(x, 0) | x > 0 = Infinity;
        | else  = -Infinity;
f(x, y) = (double)x / y;

Hey, C ++ da desen eşleştirmeyi destekler.

static const int PositiveInfinity = -1;
static const int NegativeInfinity = -2;
static const int NaN = -3;

template <int x, int y> struct Divide {
  enum { value = x / y };
};
template <bool x_gt_0> struct aux { enum { value = PositiveInfinity }; };
template <> struct aux<false> { enum { value = NegativeInfinity }; };
template <int x> struct Divide<x, 0> {
  enum { value = aux<(x>0)>::value };
};
template <> struct Divide<0, 0> {
  enum { value = NaN };
};

#include <cstdio>

int main () {
    printf("%d %d %d %d\n", Divide<7,2>::value, Divide<1,0>::value, Divide<0,0>::value, Divide<-1,0>::value);
    return 0;
};

1
Scala'da: import Double._ def divide = {değerler: (Double, Double) => değerler eşleşir {case (0,0) => NaN case (x, 0) => if (x> 0) PositiveInfinity else NegativeInfinity case (x, y) => x / y}}
fracca

12

Örüntü eşleştirme, steroidler üzerindeki aşırı yüklenmiş yöntemlere benzer. En basit durum, java'da gördüklerinizle hemen hemen aynı olacaktır, argümanlar isimleri olan türlerin bir listesidir. Çağrılacak doğru yöntem, iletilen argümanlara dayanır ve bu argümanların parametre adına atanması olarak iki katına çıkar.

Kalıplar sadece bir adım daha ileri gider ve ileri sürülen argümanları daha da yok edebilir. Ayrıca, argümanın değerine göre gerçekten eşleşmek için potansiyel olarak korumaları da kullanabilir. Göstermek için, JavaScript'in kalıp eşlemesi varmış gibi yapacağım.

function foo(a,b,c){} //no pattern matching, just a list of arguments

function foo2([a],{prop1:d,prop2:e}, 35){} //invented pattern matching in JavaScript

Foo2'de, a'nın bir dizi olmasını bekler, ikinci argümanı parçalara ayırır, iki props (prop1, prop2) olan bir nesneyi bekler ve bu özelliklerin değerlerini d ve e değişkenlerine atar ve ardından üçüncü argümanın olmasını bekler. 35.

JavaScript'ten farklı olarak, kalıp eşleştirmeli diller genellikle aynı ada ancak farklı kalıplara sahip birden çok işleve izin verir. Bu şekilde yöntem aşırı yüklemesi gibidir. Erlang'da bir örnek vereceğim:

fibo(0) -> 0 ;
fibo(1) -> 1 ;
fibo(N) when N > 0 -> fibo(N-1) + fibo(N-2) .

Gözlerinizi biraz bulanıklaştırın ve bunu javascript ile hayal edebilirsiniz. Bunun gibi bir şey belki:

function fibo(0){return 0;}
function fibo(1){return 1;}
function fibo(N) when N > 0 {return fibo(N-1) + fibo(N-2);}

Fibo'yu çağırdığınızda, kullandığı uygulamanın argümanlara dayandığını, ancak Java'nın aşırı yükleme için tek araç olarak türlerle sınırlı olduğu durumlarda, örüntü eşleştirme daha fazlasını yapabilir.

Burada gösterildiği gibi fonksiyon aşırı yüklemesinin ötesinde, aynı ilke, vaka ifadeleri veya yıkıcı değerlendirmeler gibi başka yerlerde de uygulanabilir. JavaScript bile 1.7'de buna sahiptir .


8

Desen eşleştirme, kodun bir dalını seçmek için bir değeri (veya bir nesneyi) bazı desenlerle eşleştirmenize olanak tanır. C ++ açısından bakıldığında, switchifadeye biraz benzer gelebilir . İşlevsel dillerde, örüntü eşleştirme, tamsayılar gibi standart ilkel değerlerle eşleştirme için kullanılabilir. Ancak, oluşturulmuş türler için daha kullanışlıdır.

İlk olarak, ilkel değerler üzerinde desen eşleştirmesini gösterelim (genişletilmiş sözde C ++ kullanarak switch):

switch(num) {
  case 1: 
    // runs this when num == 1
  case n when n > 10: 
    // runs this when num > 10
  case _: 
    // runs this for all other cases (underscore means 'match all')
}

İkinci kullanım, (birden çok nesneyi tek bir değerde saklamanıza izin veren) tuple gibi işlevsel veri türleri ve birkaç seçenekten birini içeren bir tür oluşturmanıza olanak tanıyan ayrıştırılmış birleşimlerle ilgilidir. Bu enum, her etiketin de bazı değerleri taşıyabilmesi dışında biraz benziyor . Sözde C ++ sözdiziminde:

enum Shape { 
  Rectangle of { int left, int top, int width, int height }
  Circle of { int x, int y, int radius }
}

Bir tür değeri Shapeartık ya Rectangletüm koordinatlarla birlikte ya Circleda merkez ve yarıçap ile a içerebilir . Desen eşleştirme, türle çalışmak için bir işlev yazmanıza olanak tanır Shape:

switch(shape) { 
  case Rectangle(l, t, w, h): 
    // declares variables l, t, w, h and assigns properties
    // of the rectangle value to the new variables
  case Circle(x, y, r):
    // this branch is run for circles (properties are assigned to variables)
}

Son olarak, her iki özelliği birleştiren iç içe desenler de kullanabilirsiniz . Örneğin Circle(0, 0, radius), merkezi [0, 0] noktasında olan ve herhangi bir yarıçapı olan tüm şekiller için eşleştirme yapmak için kullanabilirsiniz (yarıçapın değeri yeni değişkene atanacaktır radius).

Bu, C ++ açısından biraz alışılmadık gelebilir, ancak umarım sözde-C ++ 'nın açıklamayı netleştirmesi gerekir. Fonksiyonel programlama oldukça farklı kavramlara dayanmaktadır, bu nedenle fonksiyonel bir dilde daha mantıklıdır!


5

Kalıp eşleştirme, diliniz için yorumlayıcının ona verdiğiniz argümanların yapısına ve içeriğine bağlı olarak belirli bir işlevi seçeceği yerdir.

Yalnızca işlevsel bir dil özelliği değildir, aynı zamanda birçok farklı dilde mevcuttur.

Bu fikirle ilk karşılaşmam, dilin gerçekten merkezi olduğu prologu öğrendiğim zamandı.

Örneğin

last ([LastItem], LastItem).

last ([Head | Tail], LastItem): - last (Tail, LastItem).

Yukarıdaki kod, bir listenin son öğesini verecektir. Giriş argümanı birinci ve sonuç ikincidir.

Listede yalnızca bir öğe varsa, yorumlayıcı ilk sürümü seçecek ve ikinci bağımsız değişken birinciye eşit olacak şekilde ayarlanacaktır, yani sonuca bir değer atanacaktır.

Listenin hem başı hem de kuyruğu varsa, yorumlayıcı ikinci versiyonu seçer ve listede yalnızca bir öğe kalana kadar tekrar eder.


Ayrıca örnekte de görebileceğiniz gibi, yorumlayıcı tek bir argümanı otomatik olarak birkaç değişkene bölebilir (örn. [Yazı | Kuyruk])
charlieb

4

Pek çok insan için, bazı kolay örnekler verilirse yeni bir konsept seçmek daha kolaydır, işte başlıyoruz:

Diyelim ki üç tam sayıdan oluşan bir listeniz var ve birinci ve üçüncü elemanı eklemek istiyorsunuz. Desen eşleştirmesi olmadan bunu şu şekilde yapabilirsiniz (Haskell'deki örnekler):

Prelude> let is = [1,2,3]
Prelude> head is + is !! 2
4

Şimdi, bu oyuncak bir örnek olsa da, birinci ve üçüncü tamsayıyı değişkenlere bağlamak ve onları toplamak istediğimizi hayal edin:

addFirstAndThird is =
    let first = head is
        third = is !! 3
    in first + third

Bir veri yapısından bu değerlerin çıkarılması, örüntü eşleştirmenin yaptığı şeydir. Temelde bir şeyin yapısını "yansıtırsınız", ilgi alanları için bağlanacak değişkenler verirsiniz:

addFirstAndThird [first,_,third] = first + third

Bu işlevi argüman olarak [1,2,3] ile çağırdığınızda, [1,2,3] [birinci _, üçüncü] ile birleşecek, önce 1'e, üçüncüye 3'e bağlanacak ve 2 atılacak ( _bir yer tutucudur umursamadığınız şeyler için).

Şimdi, listeleri yalnızca ikinci öğe olarak 2 ile eşleştirmek istiyorsanız, bunu şu şekilde yapabilirsiniz:

addFirstAndThird [first,2,third] = first + third

Bu, yalnızca ikinci öğesi 2 olan listeler için çalışır ve aksi takdirde bir istisna oluşturur, çünkü eşleşmeyen listeler için addFirstAndThird için bir tanım verilmez.

Şimdiye kadar, örüntü eşlemeyi yalnızca bağlamayı yok etmek için kullandık. Bunun üzerinde, ilk eşleşen tanımın kullanıldığı aynı işlevin birden çok tanımını verebilirsiniz, bu nedenle, kalıp eşleme biraz "stereoidler üzerindeki bir anahtar deyimi" gibidir:

addFirstAndThird [first,2,third] = first + third
addFirstAndThird _ = 0

addFirstAndThird, mutlu bir şekilde listelerin birinci ve üçüncü öğesini ikinci öğeleri olarak ekleyecektir ve aksi takdirde "geçip gidecek" ve "geri dönecek" 0 olacaktır. Bu "anahtar benzeri" işlevsellik yalnızca işlev tanımlarında kullanılamaz, örneğin:

Prelude> case [1,3,3] of [a,2,c] -> a+c; _ -> 0
0
Prelude> case [1,2,3] of [a,2,c] -> a+c; _ -> 0
4

Ayrıca, listelerle sınırlı değildir, ancak diğer türlerle de kullanılabilir; örneğin, değeri "açmak" için Belki türünün Yalnızca ve Hiçbir Şey değer yapıcılarını eşleştirmek:

Prelude> case (Just 1) of (Just x) -> succ x; Nothing -> 0
2
Prelude> case Nothing of (Just x) -> succ x; Nothing -> 0
0

Elbette bunlar sadece oyuncak örneklerdi ve resmi ya da ayrıntılı bir açıklama yapmaya bile çalışmadım, ancak temel kavramı kavramak için yeterli olmalılar.


3

Oldukça iyi bir açıklama veren Wikipedia sayfasıyla başlamalısınız . Ardından, Haskell wiki kitabının ilgili bölümünü okuyun .

Bu, yukarıdaki wikibook'tan güzel bir tanım:

Öyleyse örüntü eşleştirme, nesnelere ad atamanın (veya bu adları bu şeylere bağlamanın) ve muhtemelen ifadeleri aynı anda alt ifadelere ayırmanın bir yoludur (harita tanımındaki listede yaptığımız gibi).


3
Bir dahaki sefere söz konusu olduğunda zaten wikipedia okuduğumu ve çok kötü bir açıklama verdiğinden bahsedeceğim.
Roman

2

İşte kalıp eşleştirmenin kullanışlılığını gösteren gerçekten kısa bir örnek:

Listedeki bir öğeyi sıralamak istediğinizi varsayalım:

["Venice","Paris","New York","Amsterdam"] 

için ("New York" u sıraladım)

["Venice","New York","Paris","Amsterdam"] 

daha zorunlu bir dilde şöyle yazarsınız:

function up(city, cities){  
    for(var i = 0; i < cities.length; i++){
        if(cities[i] === city && i > 0){
            var prev = cities[i-1];
            cities[i-1] = city;
            cities[i] = prev;
        }
    }
    return cities;
}

İşlevsel bir dilde bunun yerine şunu yazarsınız:

let up list value =  
    match list with
        | [] -> []
        | previous::current::tail when current = value ->  current::previous::tail
        | current::tail -> current::(up tail value)

Model uyumlu çözümün daha az gürültüye sahip olduğunu görebileceğiniz gibi, farklı durumların neler olduğunu ve listemizi gezip yapmanın ne kadar kolay olduğunu açıkça görebilirsiniz.

Burada bununla ilgili daha detaylı bir blog yazısı yazdım .

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.